From cf1ea2646fedfd4ef1d098e7323388a99de7d9af Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Fri, 17 Jul 2026 13:46:07 +0800 Subject: [PATCH] refactor(canvas-project): extract pure helpers into @/lib/canvas modules Move the ~30 module-scope pure functions out of the 3.5k-line project.tsx into three focused lib modules, with identical names and signatures. No behavior change; project.tsx imports them back. - canvas-node-factory.ts: createCanvasNode, *Metadata builders, referenceUrl, applyNodeConfigPatch. - canvas-node-geometry.ts: nodeBounds, group drop/snap, connection anchors, normalizeConnection, hidden-batch checks. - canvas-generation-helpers.ts: generation config/inputs, reference hydration, retry-source lookup, angle prompt builders, misc pure helpers. project.tsx drops from 3536 to ~3230 lines. Also removes imports that were only used by the moved helpers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/canvas/canvas-generation-helpers.ts | 159 ++++ web/src/lib/canvas/canvas-node-factory.ts | 71 ++ web/src/lib/canvas/canvas-node-geometry.ts | 91 +++ web/src/pages/canvas/project.tsx | 684 ++++++++---------- 4 files changed, 603 insertions(+), 402 deletions(-) create mode 100644 web/src/lib/canvas/canvas-generation-helpers.ts create mode 100644 web/src/lib/canvas/canvas-node-factory.ts create mode 100644 web/src/lib/canvas/canvas-node-geometry.ts diff --git a/web/src/lib/canvas/canvas-generation-helpers.ts b/web/src/lib/canvas/canvas-generation-helpers.ts new file mode 100644 index 0000000..462b4bb --- /dev/null +++ b/web/src/lib/canvas/canvas-generation-helpers.ts @@ -0,0 +1,159 @@ +import { defaultConfig, type AiConfig } from "@/stores/use-config-store"; +import { resolveImageUrl, uploadImage } from "@/services/image-storage"; +import { resolveMediaUrl } from "@/services/file-storage"; +import { imageMetadata, referenceUrl } from "@/lib/canvas/canvas-node-factory"; +import type { NodeGenerationInput } from "@/components/canvas/canvas-node-generation"; +import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; +import type { CanvasImageAngleParams } from "@/components/canvas/canvas-node-angle-dialog"; +import type { ReferenceImage } from "@/types/image"; +import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas"; + +export function imageExtension(dataUrl: string) { + return dataUrl.match(/^data:image[/]([^;]+)/)?.[1] || dataUrl.match(/image[/]([^;]+)/)?.[1] || "png"; +} + +export function audioExtension(mimeType?: string) { + if (mimeType?.includes("wav")) return "wav"; + if (mimeType?.includes("opus")) return "opus"; + if (mimeType?.includes("aac")) return "aac"; + if (mimeType?.includes("flac")) return "flac"; + if (mimeType?.includes("pcm")) return "pcm"; + return "mp3"; +} + +export function generationReferenceUrls(context: { referenceImages: ReferenceImage[]; referenceVideos: Array<{ storageKey?: string; url?: string }>; referenceAudios?: Array<{ storageKey?: string; url?: string }> }) { + return [ + ...context.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)), + ...context.referenceVideos.map((video) => video.storageKey || video.url).filter((url): url is string => Boolean(url)), + ...(context.referenceAudios || []).map((audio) => audio.storageKey || audio.url).filter((url): url is string => Boolean(url)), + ]; +} + +export async function resolveMetadataReferences(metadata: CanvasNodeMetadata) { + if (metadata.generationType !== "edit") return []; + if (!metadata.references?.length) return null; + const references = await Promise.all( + metadata.references.map(async (url, index) => { + const dataUrl = url.startsWith("image:") ? await resolveImageUrl(url, "") : url; + return dataUrl ? { id: `${index}`, name: `reference-${index}.png`, type: "image/png", dataUrl, storageKey: url.startsWith("image:") ? url : undefined } : null; + }), + ); + return references.every(Boolean) ? (references as ReferenceImage[]) : null; +} + +export async function hydrateCanvasImages(nodes: CanvasNodeData[]) { + return Promise.all( + nodes.map(async (node) => { + const content = node.metadata?.content; + if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content) } }; + if (node.type !== CanvasNodeType.Image || !content) return node; + if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content) } }; + if (!content.startsWith("data:image/")) return node; + return { ...node, metadata: { ...node.metadata, ...imageMetadata(await uploadImage(content)) } }; + }), + ); +} + +export async function hydrateAssistantImages(sessions: CanvasAssistantSession[]) { + const hydrateItem = async (item: T) => { + if (item.storageKey) return { ...item, dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl) }; + if (item.dataUrl?.startsWith("data:image/")) { + const image = await uploadImage(item.dataUrl); + return { ...item, dataUrl: image.url, storageKey: image.storageKey }; + } + return item; + }; + return Promise.all( + sessions.map(async (session) => ({ + ...session, + messages: await Promise.all( + session.messages.map(async (message) => ({ + ...message, + references: await Promise.all((message.references || []).map(hydrateItem)), + })), + ), + })), + ); +} + +export function getGenerationCount(count: string) { + return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(count)) || 1))); +} + +export function getInputSummary(inputs: NodeGenerationInput[]) { + return { + textCount: inputs.filter((input) => input.type === "text").length, + imageCount: inputs.filter((input) => input.type === "image").length, + videoCount: inputs.filter((input) => input.type === "video").length, + audioCount: inputs.filter((input) => input.type === "audio").length, + }; +} + +export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig { + const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel; + return { + ...config, + model: node?.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : config.model || defaultConfig.model), + quality: node?.metadata?.quality || config.quality || defaultConfig.quality, + size: node?.metadata?.size || config.size || defaultConfig.size, + background: node?.metadata?.background ?? config.background ?? defaultConfig.background, + videoSeconds: node?.metadata?.seconds || config.videoSeconds || defaultConfig.videoSeconds, + vquality: node?.metadata?.vquality || config.vquality || defaultConfig.vquality, + videoGenerateAudio: node?.metadata?.generateAudio || config.videoGenerateAudio || defaultConfig.videoGenerateAudio, + videoWatermark: node?.metadata?.watermark || config.videoWatermark || defaultConfig.videoWatermark, + audioVoice: node?.metadata?.audioVoice || config.audioVoice || defaultConfig.audioVoice, + audioFormat: node?.metadata?.audioFormat || config.audioFormat || defaultConfig.audioFormat, + audioSpeed: node?.metadata?.audioSpeed || config.audioSpeed || defaultConfig.audioSpeed, + audioInstructions: node?.metadata?.audioInstructions || config.audioInstructions || defaultConfig.audioInstructions, + count: String(node?.metadata?.count || (mode === "image" ? config.canvasImageCount || config.count : config.count) || defaultConfig.count), + }; +} + +export function resetInterruptedGeneration(nodes: CanvasNodeData[]) { + return nodes.map((node) => (node.metadata?.status === "loading" ? { ...node, metadata: { ...node.metadata, status: "error" as const, errorDetails: "页面刷新后生成已中断,请重新生成。" } } : node)); +} + +export function isGenerationCanceled(error: unknown) { + return error instanceof Error && (error.message === "请求已取消" || error.name === "AbortError"); +} + +export function findRetrySourceNode(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) { + const queue = connections.filter((connection) => connection.toNodeId === nodeId).map((connection) => connection.fromNodeId); + const visited = new Set(); + while (queue.length) { + const id = queue.shift()!; + if (visited.has(id)) continue; + visited.add(id); + const node = nodes.find((item) => item.id === id); + if (node?.type === CanvasNodeType.Config) return node; + connections.filter((connection) => connection.toNodeId === id).forEach((connection) => queue.push(connection.fromNodeId)); + } + return null; +} + +export function sourceNodeReferenceImages(node: CanvasNodeData | null) { + if (!node || node.type !== CanvasNodeType.Image || !node.metadata?.content) return []; + return [ + { + id: node.id, + name: `${node.title || node.id}.png`, + type: node.metadata.mimeType || "image/png", + dataUrl: node.metadata.content, + storageKey: node.metadata.storageKey, + }, + ]; +} + +export function isAudioFile(file: File) { + return file.type.startsWith("audio/") || /\.(mp3|wav)$/i.test(file.name); +} + +export function buildAngleLabel(params: CanvasImageAngleParams) { + const horizontal = params.horizontalAngle === 0 ? "正面视角" : params.horizontalAngle > 0 ? `向右旋转 ${params.horizontalAngle} 度` : `向左旋转 ${Math.abs(params.horizontalAngle)} 度`; + const pitch = params.pitchAngle === 0 ? "水平视角" : params.pitchAngle > 0 ? `俯视 ${params.pitchAngle} 度` : `仰视 ${Math.abs(params.pitchAngle)} 度`; + return `AI 多角度:${horizontal},${pitch},镜头距离 ${params.cameraDistance.toFixed(1)},${params.wideAngle ? "广角" : "标准"}镜头`; +} + +export function buildAnglePrompt(params: CanvasImageAngleParams) { + return `基于参考图重新生成同一主体的新视角,保持主体、颜色、材质和画面风格一致,不要只做透视变形。${buildAngleLabel(params)}。`; +} diff --git a/web/src/lib/canvas/canvas-node-factory.ts b/web/src/lib/canvas/canvas-node-factory.ts new file mode 100644 index 0000000..1693cc9 --- /dev/null +++ b/web/src/lib/canvas/canvas-node-factory.ts @@ -0,0 +1,71 @@ +import { getNodeSpec, NODE_DEFAULT_SIZE } from "@/constant/canvas"; +import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size"; +import type { AiConfig } from "@/stores/use-config-store"; +import type { UploadedImage } from "@/services/image-storage"; +import type { UploadedFile } from "@/services/file-storage"; +import type { ReferenceImage } from "@/types/image"; +import { CanvasNodeType, type CanvasImageGenerationType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type Position } from "@/types/canvas"; + +export function createCanvasNode(type: CanvasNodeTypeId, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData { + const spec = getNodeSpec(type); + const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + + return { + id, + type, + title: spec.title, + position: { + x: position.x - spec.width / 2, + y: position.y - spec.height / 2, + }, + width: spec.width, + height: spec.height, + metadata: { ...spec.metadata, ...metadata }, + }; +} + +export function imageMetadata(image: UploadedImage): CanvasNodeMetadata { + return { content: image.url, storageKey: image.storageKey, status: "success", naturalWidth: image.width, naturalHeight: image.height, bytes: image.bytes, mimeType: image.mimeType }; +} + +export function videoMetadata(video: UploadedFile): CanvasNodeMetadata { + return { content: video.url, storageKey: video.storageKey, status: "success", naturalWidth: video.width, naturalHeight: video.height, bytes: video.bytes, mimeType: video.mimeType || "video/mp4", durationMs: video.durationMs }; +} + +export function audioMetadata(audio: UploadedFile): CanvasNodeMetadata { + return { content: audio.url, storageKey: audio.storageKey, status: "success", bytes: audio.bytes, mimeType: audio.mimeType || "audio/mpeg", durationMs: audio.durationMs }; +} + +export function referenceUrl(image: ReferenceImage) { + return image.storageKey || image.url || (!image.dataUrl.startsWith("data:") ? image.dataUrl : undefined); +} + +export function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: AiConfig, count: number, references: ReferenceImage[]): CanvasNodeMetadata { + return { + generationType: type, + model: config.model, + size: config.size, + quality: config.quality, + ...(config.background ? { background: config.background } : {}), + count, + references: references.map(referenceUrl).filter((url): url is string => Boolean(url)), + }; +} + +export function buildAudioGenerationMetadata(config: AiConfig): CanvasNodeMetadata { + return { + model: config.model, + audioVoice: config.audioVoice, + audioFormat: config.audioFormat, + audioSpeed: config.audioSpeed, + audioInstructions: config.audioInstructions, + }; +} + +export function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial) { + const safePatch = patch || {}; + const next = { ...node, metadata: { ...node.metadata, ...safePatch } }; + const spec = node.type === CanvasNodeType.Video ? NODE_DEFAULT_SIZE[CanvasNodeType.Video] : NODE_DEFAULT_SIZE[CanvasNodeType.Image]; + const size = typeof safePatch.size === "string" && !node.metadata?.content ? nodeSizeFromRatio(safePatch.size, spec.width, spec.height) : null; + return size && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) ? { ...next, ...size, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 } } : next; +} diff --git a/web/src/lib/canvas/canvas-node-geometry.ts b/web/src/lib/canvas/canvas-node-geometry.ts new file mode 100644 index 0000000..02c5d92 --- /dev/null +++ b/web/src/lib/canvas/canvas-node-geometry.ts @@ -0,0 +1,91 @@ +import { CanvasNodeType, type CanvasNodeData, type ConnectionHandle } from "@/types/canvas"; + +export function nodeBounds(nodes: CanvasNodeData[]) { + return nodes.reduce( + (acc, node) => ({ + left: Math.min(acc.left, node.position.x), + top: Math.min(acc.top, node.position.y), + right: Math.max(acc.right, node.position.x + node.width), + bottom: Math.max(acc.bottom, node.position.y + node.height), + }), + { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity }, + ); +} + +export function findGroupDropTarget(movedIds: Set, nodes: CanvasNodeData[]) { + if (nodes.some((node) => movedIds.has(node.id) && node.type === CanvasNodeType.Group)) return null; + const movingNodes = nodes.filter((node) => movedIds.has(node.id) && node.type !== CanvasNodeType.Group); + if (!movingNodes.length) return null; + return ( + [...nodes].reverse().find((group) => { + if (group.type !== CanvasNodeType.Group || movedIds.has(group.id)) return false; + return movingNodes.some((node) => { + const centerX = node.position.x + node.width / 2; + const centerY = node.position.y + node.height / 2; + return centerX >= group.position.x && centerX <= group.position.x + group.width && centerY >= group.position.y && centerY <= group.position.y + group.height; + }); + }) || null + ); +} + +export function snapNodesIntoGroup(movedIds: Set, nodes: CanvasNodeData[], group: CanvasNodeData) { + const movingNodes = nodes.filter((node) => movedIds.has(node.id) && node.type !== CanvasNodeType.Group); + if (!movingNodes.length) return nodes; + const pad = 24; + const bounds = nodeBounds(movingNodes); + const left = group.position.x + pad; + const top = group.position.y + pad; + const right = group.position.x + group.width - pad; + const bottom = group.position.y + group.height - pad; + const dx = bounds.right - bounds.left > right - left ? left - bounds.left : bounds.left < left ? left - bounds.left : bounds.right > right ? right - bounds.right : 0; + const dy = bounds.bottom - bounds.top > bottom - top ? top - bounds.top : bounds.top < top ? top - bounds.top : bounds.bottom > bottom ? bottom - bounds.bottom : 0; + return nodes.map((node) => { + if (!movedIds.has(node.id) || node.type === CanvasNodeType.Group) return node; + return { ...node, position: { x: node.position.x + dx, y: node.position.y + dy }, metadata: { ...node.metadata, groupId: group.id } }; + }); +} + +export function findContainingGroupId(node: CanvasNodeData, nodes: CanvasNodeData[]) { + const centerX = node.position.x + node.width / 2; + const centerY = node.position.y + node.height / 2; + return ( + [...nodes] + .reverse() + .find((group) => group.type === CanvasNodeType.Group && group.id !== node.id && centerX >= group.position.x && centerX <= group.position.x + group.width && centerY >= group.position.y && centerY <= group.position.y + group.height)?.id || + undefined + ); +} + +export function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle) { + return { + x: current.handleType === "source" ? node.position.x : node.position.x + node.width, + y: node.position.y + node.height / 2, + }; +} + +export function normalizeConnection(firstNodeId: string, secondNodeId: string, nodes: CanvasNodeData[], firstHandleType: "source" | "target") { + const first = nodes.find((node) => node.id === firstNodeId); + const second = nodes.find((node) => node.id === secondNodeId); + if (!first || !second || first.id === second.id) return null; + if (first.type === CanvasNodeType.Group || second.type === CanvasNodeType.Group) return null; + if (first.type === CanvasNodeType.Config && second.type === CanvasNodeType.Config) return null; + if (second.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id }; + if (first.type === CanvasNodeType.Config && firstHandleType === "target") return { fromNodeId: second.id, toNodeId: first.id }; + if (first.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id }; + return { fromNodeId: first.id, toNodeId: second.id }; +} + +export function isHiddenBatchChild(node: CanvasNodeData, nodes: CanvasNodeData[], collapsingBatchIds?: Set) { + const rootId = node.metadata?.batchRootId; + if (!rootId) return false; + const root = nodes.find((item) => item.id === rootId); + if (root && collapsingBatchIds?.has(rootId)) return false; + return Boolean(root && !root.metadata?.imageBatchExpanded); +} + +export function isHiddenBatchConnectionEndpoint(node: CanvasNodeData, nodes: CanvasNodeData[]) { + const rootId = node.metadata?.batchRootId; + if (!rootId) return false; + const root = nodes.find((item) => item.id === rootId); + return Boolean(root && !root.metadata?.imageBatchExpanded); +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 57e48a0..3ca1c4e 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -9,8 +9,8 @@ import { requestAudioGeneration, storeGeneratedAudio } from "@/services/api/audi import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video"; import { DOCS_URL } from "@/constant/env"; import { defaultConfig, decodeChannelModel, selectableModelsByCapability, type AiConfig, type ModelCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; -import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage"; -import { resolveMediaUrl, uploadMediaFile, type UploadedFile } from "@/services/file-storage"; +import { uploadImage } from "@/services/image-storage"; +import { uploadMediaFile } from "@/services/file-storage"; import { nanoid } from "nanoid"; import { getDataUrlByteSize, readImageMeta } from "@/lib/image-utils"; import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme"; @@ -44,6 +44,26 @@ import { useAgentStore } from "@/stores/use-agent-store"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops"; import { buildNodeMentionReferences, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; +import { applyNodeConfigPatch, audioMetadata, buildAudioGenerationMetadata, buildImageGenerationMetadata, createCanvasNode, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-node-factory"; +import { findContainingGroupId, findGroupDropTarget, getConnectionTargetAnchor, isHiddenBatchChild, isHiddenBatchConnectionEndpoint, normalizeConnection, snapNodesIntoGroup } from "@/lib/canvas/canvas-node-geometry"; +import { + audioExtension, + buildAngleLabel, + buildAnglePrompt, + buildGenerationConfig, + findRetrySourceNode, + generationReferenceUrls, + getGenerationCount, + getInputSummary, + hydrateAssistantImages, + hydrateCanvasImages, + imageExtension, + isAudioFile, + isGenerationCanceled, + resetInterruptedGeneration, + resolveMetadataReferences, + sourceNodeReferenceImages, +} from "@/lib/canvas/canvas-generation-helpers"; import { getNodeDefinition, isBuiltinNodeType as isBuiltinType, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry"; import { buildNodeContext } from "@/lib/canvas/plugin-node-context"; import { ensurePluginsLoaded } from "@/lib/canvas/plugin-loader"; @@ -55,7 +75,6 @@ import { type CanvasAssistantImage, type CanvasAssistantSession, type CanvasConnection, - type CanvasImageGenerationType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, @@ -117,24 +136,6 @@ const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用 2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。 3. 尽量写成可直接用于生图模型的完整提示词。`; -function createCanvasNode(type: CanvasNodeTypeId, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData { - const spec = getNodeSpec(type); - const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; - - return { - id, - type, - title: spec.title, - position: { - x: position.x - spec.width / 2, - y: position.y - spec.height / 2, - }, - width: spec.width, - height: spec.height, - metadata: { ...spec.metadata, ...metadata }, - }; -} - export default function CanvasPage() { const [mounted, setMounted] = useState(false); @@ -182,7 +183,15 @@ function CanvasRefreshShell() { ); } -function ConnectionCreateMenu({ pending, onCreate, onClose }: { pending: PendingConnectionCreate; onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio) => void; onClose: () => void }) { +function ConnectionCreateMenu({ + pending, + onCreate, + onClose, +}: { + pending: PendingConnectionCreate; + onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio) => void; + onClose: () => void; +}) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; return (
void }) { return ( - ); @@ -247,7 +267,9 @@ function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; o onPointerDown={(event) => event.stopPropagation()} >
- 选择节点 + + 选择节点 + @@ -416,13 +438,7 @@ function InfiniteCanvasPage() { }); setRunningNodeId((current) => (current === runningId ? null : current)); if (!affectedNodeIds.size) return; - setNodes((prev) => - prev.map((node) => - affectedNodeIds.has(node.id) && node.metadata?.status === NODE_STATUS_LOADING - ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } - : node, - ), - ); + setNodes((prev) => prev.map((node) => (affectedNodeIds.has(node.id) && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node))); }, []); const confirmStopGeneration = useCallback( @@ -486,7 +502,15 @@ function InfiniteCanvasPage() { if (!projectLoaded || applyingHistoryRef.current || historyPausedRef.current) return; const next = createHistoryEntry(); const previous = lastHistoryRef.current; - if (previous?.nodes === next.nodes && previous.connections === next.connections && previous.chatSessions === next.chatSessions && previous.activeChatId === next.activeChatId && previous.backgroundMode === next.backgroundMode && previous.showImageInfo === next.showImageInfo) return; + if ( + previous?.nodes === next.nodes && + previous.connections === next.connections && + previous.chatSessions === next.chatSessions && + previous.activeChatId === next.activeChatId && + previous.backgroundMode === next.backgroundMode && + previous.showImageInfo === next.showImageInfo + ) + return; if (historyCommitTimerRef.current) clearTimeout(historyCommitTimerRef.current); historyCommitTimerRef.current = setTimeout(() => { @@ -588,10 +612,13 @@ function InfiniteCanvasPage() { } }, []); - const keepNodeToolbar = useCallback((nodeId: string) => { - if (nodeDraggingRef.current || nodeImageSettingsOpen || !selectedNodeIdsRef.current.has(nodeId)) return; - setToolbarNodeId(nodeId); - }, [nodeImageSettingsOpen]); + const keepNodeToolbar = useCallback( + (nodeId: string) => { + if (nodeDraggingRef.current || nodeImageSettingsOpen || !selectedNodeIdsRef.current.has(nodeId)) return; + setToolbarNodeId(nodeId); + }, + [nodeImageSettingsOpen], + ); const hideNodeToolbar = useCallback(() => {}, []); @@ -771,7 +798,10 @@ function InfiniteCanvasPage() { const safeOps = Array.isArray(ops) ? ops.filter((op) => op?.type) : []; const before = { projectId, title: currentProject?.title || "未命名画布", nodes: nodesRef.current, connections: connectionsRef.current, selectedNodeIds: Array.from(selectedNodeIdsRef.current), viewport: viewportRef.current }; const generationOps = safeOps.filter((op): op is Extract => op.type === "run_generation" && Boolean(op.nodeId)); - const next = applyCanvasAgentOps(before, safeOps.filter((op) => op.type !== "run_generation")); + const next = applyCanvasAgentOps( + before, + safeOps.filter((op) => op.type !== "run_generation"), + ); nodesRef.current = next.nodes; connectionsRef.current = next.connections; selectedNodeIdsRef.current = new Set(next.selectedNodeIds); @@ -787,7 +817,7 @@ function InfiniteCanvasPage() { queueMicrotask(() => generationOps.forEach((op) => { const target = nodesRef.current.find((node) => node.id === op.nodeId); - const prompt = op.prompt?.trim() ? op.prompt : target?.metadata?.composerContent ?? target?.metadata?.prompt ?? ""; + const prompt = op.prompt?.trim() ? op.prompt : (target?.metadata?.composerContent ?? target?.metadata?.prompt ?? ""); void generateNodeRef.current?.(op.nodeId, op.mode || target?.metadata?.generationMode || "image", prompt); }), ); @@ -820,8 +850,7 @@ function InfiniteCanvasPage() { // 提供给插件节点的宿主能力(节点无关,方法接收 nodeId) const pluginAi = useMemo(() => { // 把插件传入的参考图(dataURL 或 URL)整理成宿主生成 API 需要的 ReferenceImage[] - const toReferences = (refs?: string[]): ReferenceImage[] => - (refs || []).filter(Boolean).map((src, index) => ({ id: `plugin-ref-${index}`, name: `ref-${index}.png`, type: "image/png", dataUrl: src })); + const toReferences = (refs?: string[]): ReferenceImage[] => (refs || []).filter(Boolean).map((src, index) => ({ id: `plugin-ref-${index}`, name: `ref-${index}.png`, type: "image/png", dataUrl: src })); // AI 配置未就绪:弹出配置弹窗并抛错,交由插件 catch 处理 const ensureReady = (config: AiConfig) => { if (!isAiConfigReady(config, config.model)) { @@ -834,13 +863,16 @@ function InfiniteCanvasPage() { const config = { ...buildGenerationConfig(effectiveConfig, undefined, "image"), count: String(options?.count || 1), ...(options?.model ? { model: options.model } : {}), ...(options?.size ? { size: options.size } : {}) }; ensureReady(config); const references = toReferences(options?.references); - const items = references.length - ? await requestEdit(config, prompt, references, undefined, { signal: options?.signal }) - : await requestGeneration(config, prompt, { signal: options?.signal }); + const items = references.length ? await requestEdit(config, prompt, references, undefined, { signal: options?.signal }) : await requestGeneration(config, prompt, { signal: options?.signal }); return { images: items.map((item) => item.dataUrl) }; }, generateVideo: async (prompt, options) => { - const config = { ...buildGenerationConfig(effectiveConfig, undefined, "video"), ...(options?.model ? { model: options.model } : {}), ...(options?.size ? { size: options.size } : {}), ...(options?.seconds ? { videoSeconds: options.seconds } : {}) }; + const config = { + ...buildGenerationConfig(effectiveConfig, undefined, "video"), + ...(options?.model ? { model: options.model } : {}), + ...(options?.size ? { size: options.size } : {}), + ...(options?.seconds ? { videoSeconds: options.seconds } : {}), + }; ensureReady(config); const file = await storeGeneratedVideo(await requestVideoGeneration(config, prompt, toReferences(options?.references), [], [], { signal: options?.signal })); return { url: file.url, mimeType: file.mimeType, width: file.width, height: file.height, durationMs: file.durationMs }; @@ -853,8 +885,7 @@ function InfiniteCanvasPage() { return { text }; }, // 列出某能力下用户已配置的模型;label 取编码值中的模型名(去掉 channel 前缀) - listModels: (capability) => - selectableModelsByCapability(effectiveConfig, capability as ModelCapability | undefined).map((value) => ({ value, label: decodeChannelModel(value)?.model || value })), + listModels: (capability) => selectableModelsByCapability(effectiveConfig, capability as ModelCapability | undefined).map((value) => ({ value, label: decodeChannelModel(value)?.model || value })), defaultModel: (capability) => buildGenerationConfig(effectiveConfig, undefined, capability).model, }; }, [effectiveConfig, isAiConfigReady, openConfigDialog]); @@ -864,8 +895,16 @@ function InfiniteCanvasPage() { getNode: (id) => nodesRef.current.find((node) => node.id === id) || null, getNodes: () => nodesRef.current, getConnections: () => connectionsRef.current, - getUpstream: (nodeId) => connectionsRef.current.filter((conn) => conn.toNodeId === nodeId).map((conn) => nodesRef.current.find((node) => node.id === conn.fromNodeId)).filter((node): node is CanvasNodeData => Boolean(node)), - getDownstream: (nodeId) => connectionsRef.current.filter((conn) => conn.fromNodeId === nodeId).map((conn) => nodesRef.current.find((node) => node.id === conn.toNodeId)).filter((node): node is CanvasNodeData => Boolean(node)), + getUpstream: (nodeId) => + connectionsRef.current + .filter((conn) => conn.toNodeId === nodeId) + .map((conn) => nodesRef.current.find((node) => node.id === conn.fromNodeId)) + .filter((node): node is CanvasNodeData => Boolean(node)), + getDownstream: (nodeId) => + connectionsRef.current + .filter((conn) => conn.fromNodeId === nodeId) + .map((conn) => nodesRef.current.find((node) => node.id === conn.toNodeId)) + .filter((node): node is CanvasNodeData => Boolean(node)), updateNode: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, ...patch } : node))), updateMetadata: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))), applyOps: (ops) => applyAgentOps(ops), @@ -1260,14 +1299,17 @@ function InfiniteCanvasPage() { // 只做选中,不启动拖拽 —— 拖拽仍由 body 的 onMouseDown(冒泡)负责,故编辑器内选词不会拖动节点。 // capture 必先于同一次事件的 body 冒泡触发,故把算好的选中集暂存,供紧随其后的拖拽入口复用,避免二次选中(shift 反选被抵消)。 const pendingSelectionRef = useRef | null>(null); - const handleNodeSelectCapture = useCallback((event: ReactMouseEvent, nodeId: string) => { - if (event.button !== 0) return; - setContextMenu(null); - setHoveredNodeId(null); - setSelectedConnectionId(null); - const { nextSelected } = selectNodeByEvent(event, nodeId); - pendingSelectionRef.current = nextSelected; - }, [selectNodeByEvent]); + const handleNodeSelectCapture = useCallback( + (event: ReactMouseEvent, nodeId: string) => { + if (event.button !== 0) return; + setContextMenu(null); + setHoveredNodeId(null); + setSelectedConnectionId(null); + const { nextSelected } = selectNodeByEvent(event, nodeId); + pendingSelectionRef.current = nextSelected; + }, + [selectNodeByEvent], + ); const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => { event.stopPropagation(); @@ -1766,7 +1808,15 @@ function InfiniteCanvasPage() { } if (node.type === CanvasNodeType.Video) { if (!node.metadata?.content) return message.error("没有可保存的视频"); - addAsset({ kind: "video", title: node.metadata?.prompt?.slice(0, 24) || "画布视频", coverUrl: "", tags: [], source: "Canvas", data: { url: node.metadata.content, storageKey: node.metadata.storageKey, width: node.width, height: node.height, bytes: node.metadata.bytes || 0, mimeType: node.metadata.mimeType || "video/mp4" }, metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt } }); + addAsset({ + kind: "video", + title: node.metadata?.prompt?.slice(0, 24) || "画布视频", + coverUrl: "", + tags: [], + source: "Canvas", + data: { url: node.metadata.content, storageKey: node.metadata.storageKey, width: node.width, height: node.height, bytes: node.metadata.bytes || 0, mimeType: node.metadata.mimeType || "video/mp4" }, + metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt }, + }); message.success("已加入我的资产"); return; } @@ -1805,11 +1855,7 @@ function InfiniteCanvasPage() { const configSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Config]; const centerY = node.position.y + node.height / 2; const textNode = { - ...createCanvasNode( - CanvasNodeType.Text, - { x: node.position.x + node.width + gap + textSpec.width / 2, y: centerY }, - { content: IMAGE_PROMPT_REVERSE_PRESET, prompt: IMAGE_PROMPT_REVERSE_PRESET, status: NODE_STATUS_SUCCESS, fontSize: 14 }, - ), + ...createCanvasNode(CanvasNodeType.Text, { x: node.position.x + node.width + gap + textSpec.width / 2, y: centerY }, { content: IMAGE_PROMPT_REVERSE_PRESET, prompt: IMAGE_PROMPT_REVERSE_PRESET, status: NODE_STATUS_SUCCESS, fontSize: 14 }), title: "反推提示词", }; const configNode = { @@ -1827,11 +1873,7 @@ function InfiniteCanvasPage() { }; setNodes((prev) => [...prev, textNode, configNode]); - setConnections((prev) => [ - ...prev, - { id: nanoid(), fromNodeId: node.id, toNodeId: configNode.id }, - { id: nanoid(), fromNodeId: textNode.id, toNodeId: configNode.id }, - ]); + setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: configNode.id }, { id: nanoid(), fromNodeId: textNode.id, toNodeId: configNode.id }]); setSelectedNodeIds(new Set([configNode.id])); setSelectedConnectionId(null); setDialogNodeId(configNode.id); @@ -2012,9 +2054,13 @@ function InfiniteCanvasPage() { setDialogNodeId(childId); const controller = startGenerationRequest(childId, node.id, childId); try { - const image = await requestEdit(generationConfig, prompt, [{ id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey }], undefined, { signal: controller.signal }).then( - (items) => items[0], - ); + const image = await requestEdit( + generationConfig, + prompt, + [{ id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey }], + undefined, + { signal: controller.signal }, + ).then((items) => items[0]); const uploaded = await uploadImage(image.dataUrl); const size = fitNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height); setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item))); @@ -2049,7 +2095,21 @@ function InfiniteCanvasPage() { if (isAudioFile(file)) { const audio = await uploadMediaFile(file, "audio"); const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio]; - setNodes((prev) => prev.map((node) => (node.id === target.nodeId ? { ...node, type: CanvasNodeType.Audio, title: file.name, position: { x: node.position.x + node.width / 2 - spec.width / 2, y: node.position.y + node.height / 2 - spec.height / 2 }, width: spec.width, height: spec.height, metadata: { ...node.metadata, ...audioMetadata(audio), errorDetails: undefined } } : node))); + setNodes((prev) => + prev.map((node) => + node.id === target.nodeId + ? { + ...node, + type: CanvasNodeType.Audio, + title: file.name, + position: { x: node.position.x + node.width / 2 - spec.width / 2, y: node.position.y + node.height / 2 - spec.height / 2 }, + width: spec.width, + height: spec.height, + metadata: { ...node.metadata, ...audioMetadata(audio), errorDetails: undefined }, + } + : node, + ), + ); setSelectedNodeIds(new Set([target.nodeId])); setSelectedConnectionId(null); uploadTargetRef.current = null; @@ -2059,7 +2119,21 @@ function InfiniteCanvasPage() { if (file.type.startsWith("video/")) { const video = await uploadMediaFile(file, "video"); const nextSize = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT); - setNodes((prev) => prev.map((node) => (node.id === target.nodeId ? { ...node, type: CanvasNodeType.Video, title: file.name, position: { x: node.position.x + node.width / 2 - nextSize.width / 2, y: node.position.y + node.height / 2 - nextSize.height / 2 }, width: nextSize.width, height: nextSize.height, metadata: { ...node.metadata, ...videoMetadata(video), errorDetails: undefined } } : node))); + setNodes((prev) => + prev.map((node) => + node.id === target.nodeId + ? { + ...node, + type: CanvasNodeType.Video, + title: file.name, + position: { x: node.position.x + node.width / 2 - nextSize.width / 2, y: node.position.y + node.height / 2 - nextSize.height / 2 }, + width: nextSize.width, + height: nextSize.height, + metadata: { ...node.metadata, ...videoMetadata(video), errorDetails: undefined }, + } + : node, + ), + ); setSelectedNodeIds(new Set([target.nodeId])); setSelectedConnectionId(null); setDialogNodeId(target.nodeId); @@ -2177,7 +2251,9 @@ function InfiniteCanvasPage() { ? await requestEdit({ ...generationConfig, count: "1" }, fullPrompt, refs, undefined, { signal: controller.signal }).then((items) => items[0]) : await requestGeneration({ ...generationConfig, count: "1" }, fullPrompt, { signal: controller.signal }).then((items) => items[0]); const uploaded = await uploadImage(image.dataUrl); - setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...imageMetadata(uploaded), prompt: scene, model: generationConfig.model, status: NODE_STATUS_SUCCESS, errorDetails: undefined } } : node))); + setNodes((prev) => + prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...imageMetadata(uploaded), prompt: scene, model: generationConfig.model, status: NODE_STATUS_SUCCESS, errorDetails: undefined } } : node)), + ); setDialogNodeId(null); } catch (error) { if (!isGenerationCanceled(error)) { @@ -2393,16 +2469,55 @@ function InfiniteCanvasPage() { position: isEmptyVideoNode ? sourceNode.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y }, width: isEmptyVideoNode ? sourceNode.width : spec.width, height: isEmptyVideoNode ? sourceNode.height : spec.height, - metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark, references: generationReferenceUrls(generationContext) }, + metadata: { + prompt: effectivePrompt, + status: NODE_STATUS_LOADING, + model: generationConfig.model, + size: generationConfig.size, + seconds: generationConfig.videoSeconds, + vquality: generationConfig.vquality, + generateAudio: generationConfig.videoGenerateAudio, + watermark: generationConfig.videoWatermark, + references: generationReferenceUrls(generationContext), + }, }; pendingChildIds = [videoId]; - setNodes((prev) => (isEmptyVideoNode ? prev.map((node) => (node.id === nodeId ? { ...node, ...videoNode } : node)) : [...prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), videoNode])); + setNodes((prev) => + isEmptyVideoNode + ? prev.map((node) => (node.id === nodeId ? { ...node, ...videoNode } : node)) + : [...prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), videoNode], + ); if (!isEmptyVideoNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: videoId }]); const controller = startGenerationRequest(videoId, nodeId, nodeId, runController); try { - const video = await storeGeneratedVideo(await requestVideoGeneration(generationConfig, effectivePrompt, generationContext.referenceImages, generationContext.referenceVideos, generationContext.referenceAudios, { signal: controller.signal })); + const video = await storeGeneratedVideo( + await requestVideoGeneration(generationConfig, effectivePrompt, generationContext.referenceImages, generationContext.referenceVideos, generationContext.referenceAudios, { signal: controller.signal }), + ); const videoSize = fitNodeSize(video.width || spec.width, video.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT); - setNodes((prev) => prev.map((node) => (node.id === videoId ? { ...node, width: videoSize.width, height: videoSize.height, position: { x: node.position.x + node.width / 2 - videoSize.width / 2, y: node.position.y + node.height / 2 - videoSize.height / 2 }, metadata: { ...node.metadata, ...videoMetadata(video), prompt: effectivePrompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark, references: generationReferenceUrls(generationContext) } } : node))); + setNodes((prev) => + prev.map((node) => + node.id === videoId + ? { + ...node, + width: videoSize.width, + height: videoSize.height, + position: { x: node.position.x + node.width / 2 - videoSize.width / 2, y: node.position.y + node.height / 2 - videoSize.height / 2 }, + metadata: { + ...node.metadata, + ...videoMetadata(video), + prompt: effectivePrompt, + model: generationConfig.model, + size: generationConfig.size, + seconds: generationConfig.videoSeconds, + vquality: generationConfig.vquality, + generateAudio: generationConfig.videoGenerateAudio, + watermark: generationConfig.videoWatermark, + references: generationReferenceUrls(generationContext), + }, + } + : node, + ), + ); } finally { finishGenerationRequest(videoId, controller); } @@ -2424,7 +2539,11 @@ function InfiniteCanvasPage() { metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...buildAudioGenerationMetadata(generationConfig) }, }; pendingChildIds = [audioId]; - setNodes((prev) => (isEmptyAudioNode ? prev.map((node) => (node.id === nodeId ? { ...node, ...audioNode } : node)) : [...prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), audioNode])); + setNodes((prev) => + isEmptyAudioNode + ? prev.map((node) => (node.id === nodeId ? { ...node, ...audioNode } : node)) + : [...prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), audioNode], + ); if (!isEmptyAudioNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: audioId }]); const controller = startGenerationRequest(audioId, nodeId, nodeId, runController); try { @@ -2467,12 +2586,19 @@ function InfiniteCanvasPage() { const answers = await Promise.all( textTargetIds.map((targetNodeId) => { let localStreamed = ""; - return requestImageQuestion(generationConfig, buildNodeResponseMessages({ ...generationContext, prompt: effectivePrompt }), (text) => { - localStreamed = text; - streamed = text; - if (isConfigNode) return; - setNodes((prev) => prev.map((node) => (node.id === targetNodeId ? { ...node, type: CanvasNodeType.Text, metadata: { ...node.metadata, content: text, status: NODE_STATUS_LOADING } } : node))); - }, { signal: controller.signal }).then((answer) => ({ nodeId: targetNodeId, content: answer || localStreamed })).finally(() => finishGenerationRequest(targetNodeId, controller)); + return requestImageQuestion( + generationConfig, + buildNodeResponseMessages({ ...generationContext, prompt: effectivePrompt }), + (text) => { + localStreamed = text; + streamed = text; + if (isConfigNode) return; + setNodes((prev) => prev.map((node) => (node.id === targetNodeId ? { ...node, type: CanvasNodeType.Text, metadata: { ...node.metadata, content: text, status: NODE_STATUS_LOADING } } : node))); + }, + { signal: controller.signal }, + ) + .then((answer) => ({ nodeId: targetNodeId, content: answer || localStreamed })) + .finally(() => finishGenerationRequest(targetNodeId, controller)); }), ); if (controller.signal.aborted) return; @@ -2553,17 +2679,44 @@ function InfiniteCanvasPage() { if (node.type === CanvasNodeType.Text) { if (!context) return; let streamed = ""; - const answer = await requestImageQuestion(generationConfig, buildNodeResponseMessages({ ...context, prompt }), (text) => { - streamed = text; - setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: text, status: NODE_STATUS_LOADING } } : item))); - }, { signal: controller.signal }); + const answer = await requestImageQuestion( + generationConfig, + buildNodeResponseMessages({ ...context, prompt }), + (text) => { + streamed = text; + setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: text, status: NODE_STATUS_LOADING } } : item))); + }, + { signal: controller.signal }, + ); setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: answer || streamed, prompt, status: NODE_STATUS_SUCCESS } } : item))); return; } if (node.type === CanvasNodeType.Video) { const video = await storeGeneratedVideo(await requestVideoGeneration(generationConfig, prompt, retryImages, context?.referenceVideos || [], context?.referenceAudios || [], { signal: controller.signal })); const videoSize = fitNodeSize(video.width || node.width, video.height || node.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT); - setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, width: videoSize.width, height: videoSize.height, position: { x: item.position.x + item.width / 2 - videoSize.width / 2, y: item.position.y + item.height / 2 - videoSize.height / 2 }, metadata: { ...item.metadata, ...videoMetadata(video), prompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark } } : item))); + setNodes((prev) => + prev.map((item) => + item.id === node.id + ? { + ...item, + width: videoSize.width, + height: videoSize.height, + position: { x: item.position.x + item.width / 2 - videoSize.width / 2, y: item.position.y + item.height / 2 - videoSize.height / 2 }, + metadata: { + ...item.metadata, + ...videoMetadata(video), + prompt, + model: generationConfig.model, + size: generationConfig.size, + seconds: generationConfig.videoSeconds, + vquality: generationConfig.vquality, + generateAudio: generationConfig.videoGenerateAudio, + watermark: generationConfig.videoWatermark, + }, + } + : item, + ), + ); return; } if (node.type === CanvasNodeType.Audio) { @@ -2572,12 +2725,22 @@ function InfiniteCanvasPage() { return; } - const image = useReferenceImages ? await requestEdit(generationConfig, prompt, retryImages, undefined, { signal: controller.signal }).then((items) => items[0]) : await requestGeneration(generationConfig, prompt, { signal: controller.signal }).then((items) => items[0]); + const image = useReferenceImages + ? await requestEdit(generationConfig, prompt, retryImages, undefined, { signal: controller.signal }).then((items) => items[0]) + : await requestGeneration(generationConfig, prompt, { signal: controller.signal }).then((items) => items[0]); const uploadedImage = await uploadImage(image.dataUrl); const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height); const generationMetadata = savedImageMetadata?.generationType - ? { generationType: savedImageMetadata.generationType, model: generationConfig.model, size: generationConfig.size, quality: generationConfig.quality, ...(generationConfig.background ? { background: generationConfig.background } : {}), count: savedImageMetadata.count || 1, references: savedImageMetadata.references } + ? { + generationType: savedImageMetadata.generationType, + model: generationConfig.model, + size: generationConfig.size, + quality: generationConfig.quality, + ...(generationConfig.background ? { background: generationConfig.background } : {}), + count: savedImageMetadata.count || 1, + references: savedImageMetadata.references, + } : buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryImages); setNodes((prev) => prev.map((item) => @@ -2691,7 +2854,18 @@ function InfiniteCanvasPage() { const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2); const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const nextSize = fitNodeSize(payload.width || spec.width, payload.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT); - setNodes((prev) => [...prev, { id, type: CanvasNodeType.Video, title: payload.title, position: { x: center.x - nextSize.width / 2, y: center.y - nextSize.height / 2 }, width: nextSize.width, height: nextSize.height, metadata: { content: payload.url, storageKey: payload.storageKey, status: NODE_STATUS_SUCCESS, naturalWidth: payload.width, naturalHeight: payload.height } }]); + setNodes((prev) => [ + ...prev, + { + id, + type: CanvasNodeType.Video, + title: payload.title, + position: { x: center.x - nextSize.width / 2, y: center.y - nextSize.height / 2 }, + width: nextSize.width, + height: nextSize.height, + metadata: { content: payload.url, storageKey: payload.storageKey, status: NODE_STATUS_SUCCESS, naturalWidth: payload.width, naturalHeight: payload.height }, + }, + ]); setSelectedNodeIds(new Set([id])); } else { insertAssistantImage({ id: `asset-${Date.now()}`, prompt: payload.title, dataUrl: payload.dataUrl, storageKey: payload.storageKey }); @@ -2725,7 +2899,12 @@ function InfiniteCanvasPage() { getNodeDefinition(panelNode.type)?.Panel ? ( renderPluginPanel(panelNode) ) : panelNode.type === CanvasNodeType.Config ? ( - handleConfigNodeChange(panelNode.id, { composerContent })} onClose={() => setDialogNodeId(null)} /> + handleConfigNodeChange(panelNode.id, { composerContent })} + onClose={() => setDialogNodeId(null)} + /> ) : ( setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null} - {maskEditNode?.metadata?.content ? setMaskEditNodeId(null)} onConfirm={(payload) => void maskEditImageNode(maskEditNode!, payload)} /> : null} + {maskEditNode?.metadata?.content ? ( + setMaskEditNodeId(null)} onConfirm={(payload) => void maskEditImageNode(maskEditNode!, payload)} /> + ) : null} {splitNode?.metadata?.content ? setSplitNodeId(null)} onConfirm={(params) => void splitImageNode(splitNode!, params)} /> : null} - {upscaleNode?.metadata?.content ? setUpscaleNodeId(null)} onConfirm={(params) => void upscaleImageNode(upscaleNode!, params)} /> : null} + {upscaleNode?.metadata?.content ? ( + setUpscaleNodeId(null)} onConfirm={(params) => void upscaleImageNode(upscaleNode!, params)} /> + ) : null} setSuperResolveNodeId(null)}>
暂未实现
@@ -3016,13 +3199,7 @@ function InfiniteCanvasPage() { width="auto" styles={{ body: { padding: 0, display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "80vh" } }} > - {previewNode?.metadata?.content ? ( - {previewNode.title - ) : null} + {previewNode?.metadata?.content ? {previewNode.title : null}
- setShortcutsOpen(true)} - onOpenPlugins={onOpenPlugins} - /> + setShortcutsOpen(true)} onOpenPlugins={onOpenPlugins} /> @@ -3247,290 +3414,3 @@ function Shortcut({ keys, value }: { keys: string[]; value: string }) {
); } - -function imageExtension(dataUrl: string) { - return dataUrl.match(/^data:image[/]([^;]+)/)?.[1] || dataUrl.match(/image[/]([^;]+)/)?.[1] || "png"; -} - -function audioExtension(mimeType?: string) { - if (mimeType?.includes("wav")) return "wav"; - if (mimeType?.includes("opus")) return "opus"; - if (mimeType?.includes("aac")) return "aac"; - if (mimeType?.includes("flac")) return "flac"; - if (mimeType?.includes("pcm")) return "pcm"; - return "mp3"; -} - -function imageMetadata(image: UploadedImage): CanvasNodeMetadata { - return { content: image.url, storageKey: image.storageKey, status: "success", naturalWidth: image.width, naturalHeight: image.height, bytes: image.bytes, mimeType: image.mimeType }; -} - -function videoMetadata(video: UploadedFile): CanvasNodeMetadata { - return { content: video.url, storageKey: video.storageKey, status: "success", naturalWidth: video.width, naturalHeight: video.height, bytes: video.bytes, mimeType: video.mimeType || "video/mp4", durationMs: video.durationMs }; -} - -function audioMetadata(audio: UploadedFile): CanvasNodeMetadata { - return { content: audio.url, storageKey: audio.storageKey, status: "success", bytes: audio.bytes, mimeType: audio.mimeType || "audio/mpeg", durationMs: audio.durationMs }; -} - -function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: AiConfig, count: number, references: ReferenceImage[]): CanvasNodeMetadata { - return { - generationType: type, - model: config.model, - size: config.size, - quality: config.quality, - ...(config.background ? { background: config.background } : {}), - count, - references: references.map(referenceUrl).filter((url): url is string => Boolean(url)), - }; -} - -function buildAudioGenerationMetadata(config: AiConfig): CanvasNodeMetadata { - return { - model: config.model, - audioVoice: config.audioVoice, - audioFormat: config.audioFormat, - audioSpeed: config.audioSpeed, - audioInstructions: config.audioInstructions, - }; -} - -function referenceUrl(image: ReferenceImage) { - return image.storageKey || image.url || (!image.dataUrl.startsWith("data:") ? image.dataUrl : undefined); -} - -function generationReferenceUrls(context: { referenceImages: ReferenceImage[]; referenceVideos: Array<{ storageKey?: string; url?: string }>; referenceAudios?: Array<{ storageKey?: string; url?: string }> }) { - return [ - ...context.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)), - ...context.referenceVideos.map((video) => video.storageKey || video.url).filter((url): url is string => Boolean(url)), - ...(context.referenceAudios || []).map((audio) => audio.storageKey || audio.url).filter((url): url is string => Boolean(url)), - ]; -} - -async function resolveMetadataReferences(metadata: CanvasNodeMetadata) { - if (metadata.generationType !== "edit") return []; - if (!metadata.references?.length) return null; - const references = await Promise.all( - metadata.references.map(async (url, index) => { - const dataUrl = url.startsWith("image:") ? await resolveImageUrl(url, "") : url; - return dataUrl ? { id: `${index}`, name: `reference-${index}.png`, type: "image/png", dataUrl, storageKey: url.startsWith("image:") ? url : undefined } : null; - }), - ); - return references.every(Boolean) ? (references as ReferenceImage[]) : null; -} - -async function hydrateCanvasImages(nodes: CanvasNodeData[]) { - return Promise.all( - nodes.map(async (node) => { - const content = node.metadata?.content; - if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content) } }; - if (node.type !== CanvasNodeType.Image || !content) return node; - if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content) } }; - if (!content.startsWith("data:image/")) return node; - return { ...node, metadata: { ...node.metadata, ...imageMetadata(await uploadImage(content)) } }; - }), - ); -} - -async function hydrateAssistantImages(sessions: CanvasAssistantSession[]) { - const hydrateItem = async (item: T) => { - if (item.storageKey) return { ...item, dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl) }; - if (item.dataUrl?.startsWith("data:image/")) { - const image = await uploadImage(item.dataUrl); - return { ...item, dataUrl: image.url, storageKey: image.storageKey }; - } - return item; - }; - return Promise.all( - sessions.map(async (session) => ({ - ...session, - messages: await Promise.all( - session.messages.map(async (message) => ({ - ...message, - references: await Promise.all((message.references || []).map(hydrateItem)), - })), - ), - })), - ); -} - -function getGenerationCount(count: string) { - return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(count)) || 1))); -} - -function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial) { - const safePatch = patch || {}; - const next = { ...node, metadata: { ...node.metadata, ...safePatch } }; - const spec = node.type === CanvasNodeType.Video ? NODE_DEFAULT_SIZE[CanvasNodeType.Video] : NODE_DEFAULT_SIZE[CanvasNodeType.Image]; - const size = typeof safePatch.size === "string" && !node.metadata?.content ? nodeSizeFromRatio(safePatch.size, spec.width, spec.height) : null; - return size && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) ? { ...next, ...size, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 } } : next; -} - -function findGroupDropTarget(movedIds: Set, nodes: CanvasNodeData[]) { - if (nodes.some((node) => movedIds.has(node.id) && node.type === CanvasNodeType.Group)) return null; - const movingNodes = nodes.filter((node) => movedIds.has(node.id) && node.type !== CanvasNodeType.Group); - if (!movingNodes.length) return null; - return ( - [...nodes] - .reverse() - .find((group) => { - if (group.type !== CanvasNodeType.Group || movedIds.has(group.id)) return false; - return movingNodes.some((node) => { - const centerX = node.position.x + node.width / 2; - const centerY = node.position.y + node.height / 2; - return centerX >= group.position.x && centerX <= group.position.x + group.width && centerY >= group.position.y && centerY <= group.position.y + group.height; - }); - }) || null - ); -} - -function snapNodesIntoGroup(movedIds: Set, nodes: CanvasNodeData[], group: CanvasNodeData) { - const movingNodes = nodes.filter((node) => movedIds.has(node.id) && node.type !== CanvasNodeType.Group); - if (!movingNodes.length) return nodes; - const pad = 24; - const bounds = nodeBounds(movingNodes); - const left = group.position.x + pad; - const top = group.position.y + pad; - const right = group.position.x + group.width - pad; - const bottom = group.position.y + group.height - pad; - const dx = bounds.right - bounds.left > right - left ? left - bounds.left : bounds.left < left ? left - bounds.left : bounds.right > right ? right - bounds.right : 0; - const dy = bounds.bottom - bounds.top > bottom - top ? top - bounds.top : bounds.top < top ? top - bounds.top : bounds.bottom > bottom ? bottom - bounds.bottom : 0; - return nodes.map((node) => { - if (!movedIds.has(node.id) || node.type === CanvasNodeType.Group) return node; - return { ...node, position: { x: node.position.x + dx, y: node.position.y + dy }, metadata: { ...node.metadata, groupId: group.id } }; - }); -} - -function nodeBounds(nodes: CanvasNodeData[]) { - return nodes.reduce( - (acc, node) => ({ - left: Math.min(acc.left, node.position.x), - top: Math.min(acc.top, node.position.y), - right: Math.max(acc.right, node.position.x + node.width), - bottom: Math.max(acc.bottom, node.position.y + node.height), - }), - { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity }, - ); -} - -function findContainingGroupId(node: CanvasNodeData, nodes: CanvasNodeData[]) { - const centerX = node.position.x + node.width / 2; - const centerY = node.position.y + node.height / 2; - return ( - [...nodes] - .reverse() - .find((group) => group.type === CanvasNodeType.Group && group.id !== node.id && centerX >= group.position.x && centerX <= group.position.x + group.width && centerY >= group.position.y && centerY <= group.position.y + group.height)?.id || undefined - ); -} - -function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle) { - return { - x: current.handleType === "source" ? node.position.x : node.position.x + node.width, - y: node.position.y + node.height / 2, - }; -} - -function normalizeConnection(firstNodeId: string, secondNodeId: string, nodes: CanvasNodeData[], firstHandleType: "source" | "target") { - const first = nodes.find((node) => node.id === firstNodeId); - const second = nodes.find((node) => node.id === secondNodeId); - if (!first || !second || first.id === second.id) return null; - if (first.type === CanvasNodeType.Group || second.type === CanvasNodeType.Group) return null; - if (first.type === CanvasNodeType.Config && second.type === CanvasNodeType.Config) return null; - if (second.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id }; - if (first.type === CanvasNodeType.Config && firstHandleType === "target") return { fromNodeId: second.id, toNodeId: first.id }; - if (first.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id }; - return { fromNodeId: first.id, toNodeId: second.id }; -} - -function getInputSummary(inputs: NodeGenerationInput[]) { - return { - textCount: inputs.filter((input) => input.type === "text").length, - imageCount: inputs.filter((input) => input.type === "image").length, - videoCount: inputs.filter((input) => input.type === "video").length, - audioCount: inputs.filter((input) => input.type === "audio").length, - }; -} - -function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig { - const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel; - return { - ...config, - model: node?.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : config.model || defaultConfig.model), - quality: node?.metadata?.quality || config.quality || defaultConfig.quality, - size: node?.metadata?.size || config.size || defaultConfig.size, - background: node?.metadata?.background ?? config.background ?? defaultConfig.background, - videoSeconds: node?.metadata?.seconds || config.videoSeconds || defaultConfig.videoSeconds, - vquality: node?.metadata?.vquality || config.vquality || defaultConfig.vquality, - videoGenerateAudio: node?.metadata?.generateAudio || config.videoGenerateAudio || defaultConfig.videoGenerateAudio, - videoWatermark: node?.metadata?.watermark || config.videoWatermark || defaultConfig.videoWatermark, - audioVoice: node?.metadata?.audioVoice || config.audioVoice || defaultConfig.audioVoice, - audioFormat: node?.metadata?.audioFormat || config.audioFormat || defaultConfig.audioFormat, - audioSpeed: node?.metadata?.audioSpeed || config.audioSpeed || defaultConfig.audioSpeed, - audioInstructions: node?.metadata?.audioInstructions || config.audioInstructions || defaultConfig.audioInstructions, - count: String(node?.metadata?.count || (mode === "image" ? config.canvasImageCount || config.count : config.count) || defaultConfig.count), - }; -} - -function resetInterruptedGeneration(nodes: CanvasNodeData[]) { - return nodes.map((node) => (node.metadata?.status === "loading" ? { ...node, metadata: { ...node.metadata, status: "error" as const, errorDetails: "页面刷新后生成已中断,请重新生成。" } } : node)); -} - -function isGenerationCanceled(error: unknown) { - return error instanceof Error && (error.message === "请求已取消" || error.name === "AbortError"); -} - -function findRetrySourceNode(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) { - const queue = connections.filter((connection) => connection.toNodeId === nodeId).map((connection) => connection.fromNodeId); - const visited = new Set(); - while (queue.length) { - const id = queue.shift()!; - if (visited.has(id)) continue; - visited.add(id); - const node = nodes.find((item) => item.id === id); - if (node?.type === CanvasNodeType.Config) return node; - connections.filter((connection) => connection.toNodeId === id).forEach((connection) => queue.push(connection.fromNodeId)); - } - return null; -} - -function sourceNodeReferenceImages(node: CanvasNodeData | null) { - if (!node || node.type !== CanvasNodeType.Image || !node.metadata?.content) return []; - return [ - { - id: node.id, - name: `${node.title || node.id}.png`, - type: node.metadata.mimeType || "image/png", - dataUrl: node.metadata.content, - storageKey: node.metadata.storageKey, - }, - ]; -} - -function isAudioFile(file: File) { - return file.type.startsWith("audio/") || /\.(mp3|wav)$/i.test(file.name); -} - -function isHiddenBatchChild(node: CanvasNodeData, nodes: CanvasNodeData[], collapsingBatchIds?: Set) { - const rootId = node.metadata?.batchRootId; - if (!rootId) return false; - const root = nodes.find((item) => item.id === rootId); - if (root && collapsingBatchIds?.has(rootId)) return false; - return Boolean(root && !root.metadata?.imageBatchExpanded); -} - -function isHiddenBatchConnectionEndpoint(node: CanvasNodeData, nodes: CanvasNodeData[]) { - const rootId = node.metadata?.batchRootId; - if (!rootId) return false; - const root = nodes.find((item) => item.id === rootId); - return Boolean(root && !root.metadata?.imageBatchExpanded); -} - -function buildAngleLabel(params: CanvasImageAngleParams) { - const horizontal = params.horizontalAngle === 0 ? "正面视角" : params.horizontalAngle > 0 ? `向右旋转 ${params.horizontalAngle} 度` : `向左旋转 ${Math.abs(params.horizontalAngle)} 度`; - const pitch = params.pitchAngle === 0 ? "水平视角" : params.pitchAngle > 0 ? `俯视 ${params.pitchAngle} 度` : `仰视 ${Math.abs(params.pitchAngle)} 度`; - return `AI 多角度:${horizontal},${pitch},镜头距离 ${params.cameraDistance.toFixed(1)},${params.wideAngle ? "广角" : "标准"}镜头`; -} - -function buildAnglePrompt(params: CanvasImageAngleParams) { - return `基于参考图重新生成同一主体的新视角,保持主体、颜色、材质和画面风格一致,不要只做透视变形。${buildAngleLabel(params)}。`; -}