import { useEffect, useMemo, useState, type CSSProperties, type ReactNode, type RefObject } from "react"; import { BookOpenCheck, FileText, Image as ImageIcon, Music2, Pencil, Video } from "lucide-react"; import type { CanvasNodeRenderLOD } from "@/lib/canvas/canvas-node-lod"; import { CachedResourceImage } from "@/components/cached-resource-image"; import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text"; import { canvasTextFontSize } from "@/lib/canvas/canvas-text-scale"; import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage"; import { producedModelLabel } from "@/lib/canvas/produced-model"; import { useConfigStore } from "@/stores/use-config-store"; import { useUserStore } from "@/stores/use-user-store"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import type { CanvasTheme } from "@/lib/canvas-theme"; import { formatBytes } from "@/lib/image-utils"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; import { getNodeDefinition } from "@/lib/canvas/node-registry"; import { ART_CRITIQUE_NODE_TYPE } from "@/lib/art-critique/contracts"; import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; import { CanvasVideoPreviewImage } from "./canvas-video-preview-image"; import { CanvasFileUploadContent } from "./canvas-file-upload-content"; import { MarkdownNodeContent } from "./nodes/markdown-node"; import { ChartNodeContent } from "./nodes/chart-node"; import { CompareNodeContent } from "./nodes/compare-node"; import { ColorGradeNodeContent } from "./nodes/color-grade-node"; import { HtmlNodeContent } from "./nodes/html-node"; import { PanoramaNodeContent } from "./nodes/panorama-node"; import { SvgNodeContent } from "./nodes/svg-node"; import { ArtCritiqueNodeContent } from "./nodes/ai-art-critique-node"; import { MediaConversionNodeContent } from "./nodes/media-conversion-node"; import { MEDIA_CONVERSION_NODE_TYPE } from "@/lib/media-conversion/contracts"; import { AudioNodeContent, EmptyImageContent, ImageNodeContent, VideoNodeContent } from "./canvas-node-media-content"; import { ErrorContent, LoadingContent, UnknownNodeContent } from "./canvas-node-status-content"; export type CanvasNodeContentProps = { node: CanvasNodeData; theme: CanvasTheme; renderLOD?: CanvasNodeRenderLOD; isEditingContent: boolean; textareaRef: RefObject; isBatchRoot: boolean; batchCount: number; batchPreviewNodes?: CanvasNodeData[]; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; renderNodeContent?: (node: CanvasNodeData) => ReactNode; drawingProjectId?: string; onContentChange: (nodeId: string, content: string) => void; onStopEditing: () => void; mentionReferences: CanvasResourceReference[]; onRetry?: (node: CanvasNodeData) => void; onReloadResource?: (node: CanvasNodeData) => void; onOpenTaskDetails?: (node: CanvasNodeData) => void; onToggleBatch?: () => void; reduceMediaEffects?: boolean; mediaActive?: boolean; onMediaPlayRequest?: (nodeId: string) => void; }; export function CanvasNodeContent(props: CanvasNodeContentProps) { if (props.node.metadata?.fileUpload) return ; // Keep the image renderer mounted while node LOD changes. Visibility still gates first load. if (props.node.type === CanvasNodeType.Image && props.renderLOD !== "full" && (props.node.metadata?.content || props.node.metadata?.storageKey)) { return ; } if (props.renderLOD === "shell") return ; if (props.renderLOD === "preview") return ; const hasCustomContent = props.node.type === CanvasNodeType.Config || props.node.type === CanvasNodeType.Script || props.node.type === CanvasNodeType.BatchTable || Boolean(props.node.metadata?.directorSceneId) || (props.node.metadata?.workflowKind === "character" && Boolean(props.node.metadata.characterAssetId)) || (props.node.metadata?.workflowKind === "story_input" && !props.isEditingContent) || (props.node.metadata?.workflowKind === "styleboard" && !props.node.metadata.content); if (hasCustomContent && props.renderNodeContent) return props.renderNodeContent(props.node); if (props.node.type === ART_CRITIQUE_NODE_TYPE) return ; if (props.node.type === MEDIA_CONVERSION_NODE_TYPE) return ; if (props.isBatchRoot) return ; if (props.node.metadata?.status === "loading") return ; if (props.node.metadata?.status === "error") return ; const pluginDefinition = getNodeDefinition(props.node.type)?.plugin; if (pluginDefinition) return ; const Renderer = nodeContentRenderers[props.node.type]; return Renderer ? : ; } function CanvasNodeShellContent({ node, theme }: { node: CanvasNodeData; theme: CanvasTheme }) { const Icon = node.type === CanvasNodeType.Video ? Video : node.type === CanvasNodeType.Audio ? Music2 : node.type === CanvasNodeType.Image ? ImageIcon : FileText; return (
{node.title || "画布节点"}
); } function CanvasNodePreviewContent({ node, theme }: { node: CanvasNodeData; theme: CanvasTheme }) { if (node.type === CanvasNodeType.Image && (node.metadata?.content || node.metadata?.storageKey)) { return } />; } if (node.type === CanvasNodeType.Video && (node.metadata?.content || node.metadata?.storageKey)) { return } />; } return ; } function PluginCanvasNodeContent({ node, theme, renderer, schema }: CanvasNodeContentProps & { renderer: "declarative" | "sandbox"; schema: Record }) { if (renderer === "sandbox") { return (
插件节点等待隔离运行时
); } const data = node.metadata?.pluginData || {}; const fields = Object.keys(schema.properties && typeof schema.properties === "object" ? (schema.properties as Record) : schema); return (
{fields.length ? ( fields.map((field) => (
{field} {formatPluginValue(data[field] ?? (field === "content" ? node.metadata?.content : undefined))}
)) ) : ( {node.metadata?.content || "插件节点"} )}
); } function formatPluginValue(value: unknown) { if (value === undefined || value === null || value === "") return "未设置"; return typeof value === "string" ? value : JSON.stringify(value); } const nodeContentRenderers: Partial ReactNode>> = { [CanvasNodeType.Text]: TextContent, [CanvasNodeType.Script]: UnknownNodeContent, [CanvasNodeType.Skill]: SkillContent, [CanvasNodeType.Image]: ImageNodeContent, [CanvasNodeType.Config]: EmptyImageContent, [CanvasNodeType.Video]: VideoNodeContent, [CanvasNodeType.Audio]: AudioNodeContent, [CanvasNodeType.Drawing]: DrawingContent, [CanvasNodeType.Frame]: UnknownNodeContent, [CanvasNodeType.Markdown]: MarkdownNodeContent, [CanvasNodeType.Svg]: SvgNodeContent, [CanvasNodeType.Html]: HtmlNodeContent, [CanvasNodeType.Panorama]: PanoramaNodeContent, [CanvasNodeType.Compare]: CompareNodeContent, [CanvasNodeType.Chart]: ChartNodeContent, [CanvasNodeType.ColorGrade]: ColorGradeNodeContent, }; function DrawingContent({ node, theme, drawingProjectId }: CanvasNodeContentProps) { const shapeCount = node.metadata?.drawingShapeCount || 0; const pageCount = node.metadata?.drawingPageCount || 1; const [previewUrl, setPreviewUrl] = useState(node.metadata?.drawingPreviewUrl || ""); useEffect(() => { const drawingId = node.metadata?.drawingId; const fallbackPreview = node.metadata?.drawingPreviewUrl || ""; setPreviewUrl(fallbackPreview); if (!drawingProjectId || !drawingId) return; let active = true; let objectUrl = ""; void loadCanvasDrawingPreview(drawingProjectId, drawingId) .then((preview) => { if (!active) return; if (!preview) { setPreviewUrl(fallbackPreview); return; } objectUrl = URL.createObjectURL(preview); setPreviewUrl(objectUrl); }) .catch((error) => console.warn("读取绘图节点预览失败", error)); return () => { active = false; if (objectUrl) URL.revokeObjectURL(objectUrl); }; }, [drawingProjectId, node.metadata?.drawingId, node.metadata?.drawingPreviewUrl, node.metadata?.drawingRevision]); return (
{previewUrl ? ( 绘图预览 ) : (
打开绘图
)}
{node.title || "绘图"}
{shapeCount} 个图形 · {pageCount} 个页面
); } function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing }: CanvasNodeContentProps) { const fontSize = canvasTextFontSize(node.width, node.height, node.metadata?.fontSize); const textStyle = { fontSize: `${fontSize}px`, lineHeight: `${Math.round(fontSize * 1.65)}px`, color: theme.node.text, boxSizing: "border-box" } as CSSProperties; const richTextHTML = useMemo(() => canvasRichTextHTML(node.metadata?.richText), [node.metadata?.richText]); return (
{isEditingContent ? ( onContentChange(node.id, value)} onBlur={onStopEditing} onKeyDown={(event) => { if (event.key === "Escape") onStopEditing(); }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} /> ) : richTextHTML ? (
event.stopPropagation()} dangerouslySetInnerHTML={{ __html: richTextHTML }} /> ) : (
event.stopPropagation()}> {node.metadata?.content || 双击编辑文字}
)}
); } function SkillContent({ node, theme }: CanvasNodeContentProps) { const skill = node.metadata?.skillSnapshot; const tags = skill?.tags?.slice(0, 4) || []; const template = skill?.template || node.metadata?.content || ""; return (
{skill?.name || node.title || "技能"}
{skillCategoryLabel(skill?.category)} · {skillOutputModeLabel(skill?.outputMode)} {skill?.version ? ( <> · v{skill.version} ) : null}
{skill?.description ? (
{skill.description}
) : null}
模板
{template || "未配置技能模板"}
{tags.length ? ( tags.map((tag) => ( {tag} )) ) : ( 连接到图片、视频、音频或文本节点后生效 )}
); } function skillCategoryLabel(category?: string) { if (category === "writing") return "剧情"; if (category === "storyboard") return "分镜"; if (category === "image") return "生图"; if (category === "video") return "视频"; return "通用"; } function skillOutputModeLabel(mode?: string) { if (mode === "json") return "JSON"; if (mode === "image_prompt") return "生图提示词"; if (mode === "workflow") return "工作流"; return "文本"; } export function CanvasNodeImageInfo({ node }: { node: CanvasNodeData }) { const width = Math.round(node.metadata?.naturalWidth || node.width); const height = Math.round(node.metadata?.naturalHeight || node.height); const size = formatBytes(node.metadata?.bytes || 0); return ( {width} x {height} {size ? ` · ${size}` : ""} ); } export function CanvasNodeProducedModel({ stored }: { stored: string }) { // Keep catalog subscriptions off the CanvasNode shell and avoid normalizing config per node. const channels = useConfigStore((state) => state.config.channels); const customChannelsEnabled = useUserStore((state) => state.features.customChannelsEnabled); const visibleChannels = useMemo(() => customChannelsEnabled ? channels : channels.filter((channel) => channel.scope === "system"), [channels, customChannelsEnabled]); const label = producedModelLabel({ channels: visibleChannels }, stored); return {label}; }