import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react"; import { BookOpenCheck, Clock3, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, Play, RefreshCw, Square, Video } from "lucide-react"; import { VideoPlayer } from "@/components/video-player"; import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error"; import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text"; import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import type { CanvasTheme } from "@/lib/canvas-theme"; import { formatBytes } from "@/lib/image-utils"; import { resourceIdFromStorageKey } from "@/services/api/resources"; import { cacheResourceObjectUrl, getCachedResourceObjectUrl } from "@/services/resource-blob-cache"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; import { createDefaultSubtitleStyle } from "@/types/timeline"; import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; import { CanvasSubtitleOverlay } from "./canvas-subtitle-overlay"; type CanvasNodeContentProps = { node: CanvasNodeData; theme: CanvasTheme; isEditingContent: boolean; textareaRef: RefObject; isBatchRoot: boolean; batchCount: number; 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; onCancelTask?: (node: CanvasNodeData) => void; onOpenTaskDetails?: (node: CanvasNodeData) => void; onToggleBatch?: () => void; reduceMediaEffects?: boolean; }; export function CanvasNodeContent(props: CanvasNodeContentProps) { const hasCustomContent = props.node.type === CanvasNodeType.Config || props.node.type === CanvasNodeType.Script || 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.isBatchRoot) return ; if (props.node.metadata?.status === "loading") return ; if (props.node.metadata?.status === "error") return ; const Renderer = nodeContentRenderers[props.node.type]; return Renderer ? : ; } const nodeContentRenderers = { [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, } satisfies Record ReactNode>; 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 LoadingContent({ node, theme, onCancelTask, onOpenTaskDetails }: Pick) { const taskId = node.metadata?.taskId; const progress = typeof node.metadata?.taskProgress === "number" ? Math.max(0, Math.min(100, Math.round(node.metadata.taskProgress))) : null; const statusLabel = taskStatusLabel(node.metadata?.taskStatus); const elapsed = useTaskElapsed(node.metadata?.taskCreatedAt); return (
{node.metadata?.taskStage || (taskId ? "任务处理中" : "正在创建任务")} {taskId ? (
{statusLabel} {progress !== null ? ` · ${progress}%` : ""}
{progress !== null ? (
) : null}
{elapsed} · {shortTaskId(taskId)}
) : null}
); } function useTaskElapsed(createdAt?: string) { const [, setTick] = useState(0); useEffect(() => { if (!createdAt) return; const timer = window.setInterval(() => setTick((value) => value + 1), 1000); return () => window.clearInterval(timer); }, [createdAt]); if (!createdAt) return "刚刚"; const seconds = Math.max(0, Math.floor((Date.now() - new Date(createdAt).getTime()) / 1000)); if (seconds < 60) return `${seconds}秒`; const minutes = Math.floor(seconds / 60); return minutes < 60 ? `${minutes}分${seconds % 60}秒` : `${Math.floor(minutes / 60)}时${minutes % 60}分`; } function taskStatusLabel(status?: string) { if (status === "queued") return "排队中"; if (status === "running") return "生成中"; if (status === "succeeded") return "任务已完成"; if (status === "failed") return "任务失败"; if (status === "cancelled") return "任务已取消"; return status ? "未知任务状态" : "等待任务状态"; } function shortTaskId(id: string) { if (id.length <= 20) return id; return `${id.slice(0, 14)}...${id.slice(-4)}`; } function ErrorContent({ node, theme, onRetry }: Pick) { const moderationFailure = node.metadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(node.metadata?.errorDetails); return (
{generationErrorMessage(node.metadata?.errorDetails)}
{moderationFailure ? (
修改节点提示词后,可重新点击生成。
) : ( )}
); } function UnknownNodeContent({ theme }: Pick) { return
未知节点
; } function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing }: CanvasNodeContentProps) { const fontSize = node.metadata?.fontSize || 14; 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 "文本"; } function ImageNodeContent(props: CanvasNodeContentProps) { if (!props.node.metadata?.content && props.isBatchRoot) { const content = props.node.metadata?.status === "loading" ? : props.node.metadata?.status === "error" ? : ; return {content}; } if (!props.node.metadata?.content) return ; return ; } function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: CanvasNodeContentProps) { const isCharacterReference = node.metadata?.workflowKind === "character" && node.metadata?.characterView === "multi"; const content = (
{isCharacterReference ? (
{node.metadata?.characterName || node.title}
多视角参考 · 待生成
) : 空图片节点}
); if (isBatchRoot) return {content}; return content; } function VideoNodeContent({ node, theme, reduceMediaEffects }: CanvasNodeContentProps) { const playWhenReadyRef = useRef(false); const playerBoxRef = useRef(null); const { url, loading, load } = useNodeResourceUrl(node, false); const subtitleEntries = node.metadata?.subtitleEntries || []; const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle(); const [currentTimeMs, setCurrentTimeMs] = useState(0); const [videoSize, setVideoSize] = useState<{ width: number; height: number } | null>(null); useEffect(() => { const box = playerBoxRef.current; const video = box?.querySelector("video"); if (!video) return; const handleLoadedMetadata = () => { if (video.videoWidth > 0 && video.videoHeight > 0) setVideoSize({ width: video.videoWidth, height: video.videoHeight }); }; video.addEventListener("loadedmetadata", handleLoadedMetadata); handleLoadedMetadata(); if (!subtitleEntries.length) return () => video.removeEventListener("loadedmetadata", handleLoadedMetadata); const handleTimeUpdate = () => setCurrentTimeMs(Math.round(video.currentTime * 1000)); video.addEventListener("timeupdate", handleTimeUpdate); return () => { video.removeEventListener("timeupdate", handleTimeUpdate); video.removeEventListener("loadedmetadata", handleLoadedMetadata); }; }, [subtitleEntries.length, url]); if (!node.metadata?.content) return } label="空视频节点" color={theme.node.placeholder} />; if (!url) return : } label={loading ? "正在缓存视频" : "加载并缓存视频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />; const sourceRatio = (videoSize?.width || node.metadata?.naturalWidth || node.width) / Math.max(1, videoSize?.height || node.metadata?.naturalHeight || node.height); const fitHeight = Math.min(node.height, node.width / Math.max(0.01, sourceRatio)); const fitWidth = Math.round(fitHeight * sourceRatio); const activeEntry = subtitleEntries.find((entry) => currentTimeMs >= entry.startMs && currentTimeMs < entry.endMs); const activeHighlight = activeEntry ? (node.metadata?.subtitleHighlights || []).find((item) => item.entryIndex === activeEntry.index) : undefined; return (
{ playWhenReadyRef.current = false; }} brandColor={theme.accent.primary} className="h-full w-full rounded-[var(--node-radius)] bg-black" dataCanvasNoZoom compactControls /> {activeEntry && activeEntry.text.trim() ? : null}
); } function AudioNodeContent({ node, theme }: CanvasNodeContentProps) { const audioRef = useRef(null); const playWhenReadyRef = useRef(false); const { url, loading, load } = useNodeResourceUrl(node, false); useEffect(() => { if (!url || !playWhenReadyRef.current) return; playWhenReadyRef.current = false; void audioRef.current?.play().catch(() => undefined); }, [url]); if (!node.metadata?.content) return } label="空音频节点" color={theme.node.placeholder} />; if (!url) return : } label={loading ? "正在缓存音频" : "加载并缓存音频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />; return (
{node.title || "音频"}
); } function EmptyMediaContent({ icon, label, color }: { icon: ReactNode; label: string; color: string }) { return
{icon}{label}
; } function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: Pick) { const imageContainerRef = useRef(null); const nearViewport = useNearViewport(imageContainerRef); const { url, loading } = useNodeResourceUrl(node, nearViewport); return (
{url ? {node.title} event.preventDefault()} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> :
{loading ? : }
}
); } function DeferredMediaLoad({ icon, label, disabled, onClick }: { icon: ReactNode; label: string; disabled: boolean; onClick: () => void }) { return ; } function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) { const storageKey = node.metadata?.storageKey || ""; const fallback = node.metadata?.content || ""; const isRemoteResource = Boolean(resourceIdFromStorageKey(storageKey)); const [url, setUrl] = useState(isRemoteResource ? "" : fallback); const [loading, setLoading] = useState(isRemoteResource && eager); useEffect(() => { let cancelled = false; if (!isRemoteResource) { setUrl(fallback); setLoading(false); return; } setUrl(""); setLoading(eager); const resolve = eager ? cacheResourceObjectUrl(storageKey) : getCachedResourceObjectUrl(storageKey); void resolve.then((cached) => { if (!cancelled) setUrl(cached || (eager ? fallback : "")); }).catch(() => { if (!cancelled && eager) setUrl(fallback); }).finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [eager, fallback, isRemoteResource, storageKey]); const load = useCallback(async () => { if (url) return url; if (!isRemoteResource) return fallback; setLoading(true); try { const next = (await cacheResourceObjectUrl(storageKey)) || fallback; setUrl(next); return next; } catch { setUrl(fallback); return fallback; } finally { setLoading(false); } }, [fallback, isRemoteResource, storageKey, url]); return { url, loading, load }; } function useNearViewport(ref: RefObject) { const [nearViewport, setNearViewport] = useState(false); useEffect(() => { const element = ref.current; if (!element || typeof IntersectionObserver === "undefined") { setNearViewport(true); return; } const observer = new IntersectionObserver((entries) => { if (entries.some((entry) => entry.isIntersecting)) { setNearViewport(true); observer.disconnect(); } }, { rootMargin: "600px" }); observer.observe(element); return () => observer.disconnect(); }, [ref]); return nearViewport; } 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}` : ""}; } function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering, theme, onToggleBatch, children }: { batchCount: number; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; theme: CanvasTheme; onToggleBatch?: () => void; children: ReactNode }) { const isBatchRoot = batchCount > 1; return (
{ event.stopPropagation(); onToggleBatch?.(); } : undefined}> {isBatchRoot ? (
{Array.from({ length: Math.min(batchCount - 1, 5) }).map((_, index) => (
))}
) : null} {children}
); }