From c7cb6e14a0917470ae0fe792ad493b25a7a0801a Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Thu, 24 Sep 2026 00:05:32 +0800 Subject: [PATCH] =?UTF-8?q?perf(canvas):=20=E7=94=BB=E5=B8=83=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=20-=20=E9=99=8D=E4=BD=8E=E5=A4=A7=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=BC=80=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/canvas/canvas-frame-node.tsx | 18 +- web/src/components/canvas/canvas-mini-map.tsx | 182 ++---- .../components/canvas/canvas-node-content.tsx | 568 ++++++++++++------ web/src/components/canvas/canvas-node.tsx | 35 +- web/src/components/canvas/infinite-canvas.tsx | 3 +- web/src/lib/canvas/canvas-content.ts | 26 +- web/src/lib/canvas/canvas-node-lod.ts | 12 + web/src/lib/canvas/canvas-node-visibility.ts | 6 +- web/src/lib/canvas/task-ticker.ts | 37 ++ .../canvas/canvas-project-world-layers.tsx | 4 + web/src/pages/canvas/project.tsx | 2 + .../pages/canvas/use-canvas-active-tasks.ts | 29 +- .../canvas/use-canvas-project-lifecycle.ts | 26 +- .../pages/canvas/use-canvas-render-model.ts | 80 ++- web/src/services/user-data-sync.ts | 27 +- web/src/stores/canvas/use-canvas-store.ts | 4 + web/test/canvas-node-lod.test.ts | 27 + 17 files changed, 734 insertions(+), 352 deletions(-) create mode 100644 web/src/lib/canvas/canvas-node-lod.ts create mode 100644 web/src/lib/canvas/task-ticker.ts create mode 100644 web/test/canvas-node-lod.test.ts diff --git a/web/src/components/canvas/canvas-frame-node.tsx b/web/src/components/canvas/canvas-frame-node.tsx index e8b2f2c9a..fa9ba016b 100644 --- a/web/src/components/canvas/canvas-frame-node.tsx +++ b/web/src/components/canvas/canvas-frame-node.tsx @@ -6,6 +6,7 @@ import { CometCard } from "@/components/ui/aceternity/comet-card"; import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview"; import { CanvasVideoPreviewImage } from "@/components/canvas/canvas-video-preview-image"; import { FRAME_HEADER_HEIGHT, FRAME_PADDING, isCanvasFolderNode } from "@/lib/canvas/canvas-frame"; +import type { CanvasNodeRenderLOD } from "@/lib/canvas/canvas-node-lod"; import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { CanvasNodeType, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type Position } from "@/types/canvas"; @@ -14,6 +15,7 @@ type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; export const CanvasFrameNode = React.memo(function CanvasFrameNode({ data, + renderLOD = "full", dragOffset, childNodes, scale, @@ -33,6 +35,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({ data: CanvasNodeData; dragOffset?: Position; childNodes: CanvasNodeData[]; + renderLOD?: CanvasNodeRenderLOD; scale: number; isSelected: boolean; isDropTarget: boolean; @@ -51,6 +54,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({ const collapsed = Boolean(data.metadata?.frame?.collapsed); const folder = isCanvasFolderNode(data); const [editing, setEditing] = useState(false); + const [hovered, setHovered] = useState(false); const [title, setTitle] = useState(data.title); const resizeRef = useRef({ active: false, @@ -148,10 +152,12 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({ const active = isSelected || isDropTarget; const linkedFolder = Boolean(data.metadata?.folder?.assetFolderId); + const fullDetail = renderLOD === "full" || hovered || editing; return (
onHoverStart?.(data.id)} - onMouseLeave={() => onHoverEnd?.(data.id)} + onMouseEnter={() => { setHovered(true); onHoverStart?.(data.id); }} + onMouseLeave={() => { setHovered(false); onHoverEnd?.(data.id); }} > {/* 帧节点同样禁用指针跟随 3D 位移,hover 使用 CSS 静态抬升 */} - {folder && collapsed ? ( + {!fullDetail ? ( +
+ {data.title} · {childNodes.length} +
+ ) : folder && collapsed ? ( // 素材库目录是单一事实源;画布节点只负责浏览和调用,避免本地改名后被同步结果覆盖。 ; onViewportPreviewChange?: (viewport: ViewportTransform) => void; onViewportChange: (viewport: ViewportTransform) => void }) { +type MinimapProps = { + nodes: CanvasNodeData[]; + viewport: ViewportTransform; + viewportSize: { width: number; height: number }; + canvasContainerRef?: RefObject; + onViewportPreviewChange?: (viewport: ViewportTransform) => void; + onViewportChange: (viewport: ViewportTransform) => void; +}; + +export function Minimap({ nodes, viewport, viewportSize, canvasContainerRef, onViewportPreviewChange, onViewportChange }: MinimapProps) { const theme = canvasThemes[useActiveTheme()]; const containerRef = useRef(null); - const viewportRectRef = useRef(null); + const canvasRef = useRef(null); const liveViewportRef = useRef(viewport); const [isDragging, setIsDragging] = useState(false); const width = MINIMAP_WIDTH; const height = MINIMAP_HEIGHT; const displayNodes = useMemo(() => nodes.filter((node) => !isNodeHiddenByCollapsedFrame(node, nodes)), [nodes]); - const showImagePreviews = useMemo(() => displayNodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(getImagePreviewSource(node) || node.metadata?.storageKey)).length <= MINIMAP_IMAGE_PREVIEW_LIMIT, [displayNodes]); - const { worldBounds, scale, offset } = useMemo(() => { - if (!displayNodes.length) { - return { worldBounds: { x: -500, y: -500, w: 1000, h: 1000 }, scale: 0.16, offset: { x: 40, y: 0 } }; - } - + if (!displayNodes.length) return { worldBounds: { x: -500, y: -500, w: 1000, h: 1000 }, scale: 0.16, offset: { x: 40, y: 0 } }; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; - displayNodes.forEach((node) => { minX = Math.min(minX, node.position.x); minY = Math.min(minY, node.position.y); maxX = Math.max(maxX, node.position.x + node.width); maxY = Math.max(maxY, node.position.y + node.height); }); - minX -= 500; minY -= 500; maxX += 500; maxY += 500; - - const boundsWidth = maxX - minX; - const boundsHeight = maxY - minY; + const boundsWidth = Math.max(maxX - minX, 1); + const boundsHeight = Math.max(maxY - minY, 1); const nextScale = Math.min(width / boundsWidth, height / boundsHeight); - const mapContentW = boundsWidth * nextScale; - const mapContentH = boundsHeight * nextScale; - return { worldBounds: { x: minX, y: minY, w: boundsWidth, h: boundsHeight }, scale: nextScale, - offset: { x: (width - mapContentW) / 2, y: (height - mapContentH) / 2 }, + offset: { x: (width - boundsWidth * nextScale) / 2, y: (height - boundsHeight * nextScale) / 2 }, }; - }, [displayNodes]); - - const toMinimap = useCallback( - (worldX: number, worldY: number) => { - return { - x: (worldX - worldBounds.x) * scale + offset.x, - y: (worldY - worldBounds.y) * scale + offset.y, - }; - }, - [offset.x, offset.y, scale, worldBounds.x, worldBounds.y], - ); - - const toWorld = useCallback( - (minimapX: number, minimapY: number) => { - return { - x: (minimapX - offset.x) / scale + worldBounds.x, - y: (minimapY - offset.y) / scale + worldBounds.y, - }; - }, - [offset.x, offset.y, scale, worldBounds.x, worldBounds.y], - ); - + }, [displayNodes, height, width]); + const toMinimap = useCallback((worldX: number, worldY: number) => ({ x: (worldX - worldBounds.x) * scale + offset.x, y: (worldY - worldBounds.y) * scale + offset.y }), [offset.x, offset.y, scale, worldBounds.x, worldBounds.y]); + const toWorld = useCallback((minimapX: number, minimapY: number) => ({ x: (minimapX - offset.x) / scale + worldBounds.x, y: (minimapY - offset.y) / scale + worldBounds.y }), [offset.x, offset.y, scale, worldBounds.x, worldBounds.y]); const viewportRect = useMemo(() => { const vx = -viewport.x / viewport.k; const vy = -viewport.y / viewport.k; - const vw = viewportSize.width / viewport.k; - const vh = viewportSize.height / viewport.k; const p1 = toMinimap(vx, vy); - const p2 = toMinimap(vx + vw, vy + vh); - - return { - x: p1.x, - y: p1.y, - w: Math.max(p2.x - p1.x, 4), - h: Math.max(p2.y - p1.y, 4), - }; + const p2 = toMinimap(vx + viewportSize.width / viewport.k, vy + viewportSize.height / viewport.k); + return { x: p1.x, y: p1.y, w: Math.max(p2.x - p1.x, 4), h: Math.max(p2.y - p1.y, 4) }; }, [toMinimap, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]); - const updateViewportRect = useCallback((nextViewport: ViewportTransform) => { liveViewportRef.current = nextViewport; - const element = viewportRectRef.current; - if (!element) return; - const vx = -nextViewport.x / nextViewport.k; - const vy = -nextViewport.y / nextViewport.k; - const p1 = toMinimap(vx, vy); - const p2 = toMinimap(vx + viewportSize.width / nextViewport.k, vy + viewportSize.height / nextViewport.k); - element.style.left = `${p1.x}px`; - element.style.top = `${p1.y}px`; - element.style.width = `${Math.max(p2.x - p1.x, 4)}px`; - element.style.height = `${Math.max(p2.y - p1.y, 4)}px`; - }, [toMinimap, viewportSize.height, viewportSize.width]); + drawMinimap(canvasRef.current, displayNodes, toMinimap, nextViewport, viewportSize, theme); + }, [displayNodes, theme, toMinimap, viewportSize]); + useEffect(() => { + drawMinimap(canvasRef.current, displayNodes, toMinimap, viewport, viewportSize, theme); + }, [displayNodes, theme, toMinimap, viewport, viewportSize]); useEffect(() => updateViewportRect(viewport), [updateViewportRect, viewport]); - useEffect(() => { const canvasContainer = canvasContainerRef?.current; if (!canvasContainer) return; return subscribeCanvasViewportPreview(canvasContainer, updateViewportRect); }, [canvasContainerRef, updateViewportRect]); - const updateViewportFromEvent = (event: React.PointerEvent) => { + const updateViewportFromEvent = (event: ReactPointerEvent) => { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; - const world = toWorld(event.clientX - rect.left, event.clientY - rect.top); - const scale = liveViewportRef.current.k; - const next = { - x: viewportSize.width / 2 - world.x * scale, - y: viewportSize.height / 2 - world.y * scale, - k: scale, - }; + const current = liveViewportRef.current; + const next = { x: viewportSize.width / 2 - world.x * current.k, y: viewportSize.height / 2 - world.y * current.k, k: current.k }; liveViewportRef.current = next; onViewportPreviewChange?.(next); }; @@ -155,59 +109,37 @@ export function Minimap({ nodes, viewport, viewportSize, canvasContainerRef, onV onViewportChange(liveViewportRef.current); }} > - {displayNodes.map((node) => { - const pos = toMinimap(node.position.x, node.position.y); - const frame = isFrameNode(node); - const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Video ? "#f97316" : node.type === CanvasNodeType.Audio ? "#a855f7" : node.type === CanvasNodeType.Config ? "#60a5fa" : node.type === CanvasNodeType.Skill ? "#818cf8" : frame ? theme.frame.stroke : theme.node.muted; - const imagePreviewSource = showImagePreviews && node.type === CanvasNodeType.Image ? getImagePreviewSource(node) : ""; - const nodeLabel = node.title?.trim() || getNodeLabel(node.type); - return ( -
-
- {imagePreviewSource || (showImagePreviews && node.type === CanvasNodeType.Image && node.metadata?.storageKey) ? ( - - ) : null} -
- - {nodeLabel} - -
- ); - })} -
+ +
); } -function getImagePreviewSource(node: CanvasNodeData) { - if (node.type !== CanvasNodeType.Image) return ""; - const content = node.metadata?.content || ""; - return node.metadata?.previewContent || (node.metadata?.importSource?.provider === "libtv" ? buildLibTVImagePreviewUrl(content) : content); +function drawMinimap(canvas: HTMLCanvasElement | null, nodes: CanvasNodeData[], toMinimap: (x: number, y: number) => { x: number; y: number }, viewport: ViewportTransform, viewportSize: { width: number; height: number }, theme: (typeof canvasThemes)[keyof typeof canvasThemes]) { + if (!canvas) return; + const context = canvas.getContext("2d"); + if (!context) return; + context.clearRect(0, 0, canvas.width, canvas.height); + for (const node of nodes) { + const pos = toMinimap(node.position.x, node.position.y); + const frame = isFrameNode(node); + const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Video ? "#f97316" : node.type === CanvasNodeType.Audio ? "#a855f7" : node.type === CanvasNodeType.Config ? "#60a5fa" : node.type === CanvasNodeType.Skill ? "#818cf8" : frame ? theme.frame.stroke : theme.node.muted; + const p2 = toMinimap(node.position.x + node.width, node.position.y + node.height); + const rectWidth = Math.max(p2.x - pos.x, 2); + const rectHeight = Math.max(p2.y - pos.y, 2); + context.globalAlpha = frame ? 0.95 : 0.8; + context.fillStyle = frame && !node.metadata?.frame?.collapsed ? "transparent" : color; + context.strokeStyle = color; + context.lineWidth = frame ? 1 : 0; + if (context.fillStyle !== "transparent") context.fillRect(pos.x, pos.y, rectWidth, rectHeight); + if (frame) context.strokeRect(pos.x, pos.y, rectWidth, rectHeight); + } + const vx = -viewport.x / viewport.k; + const vy = -viewport.y / viewport.k; + const p1 = toMinimap(vx, vy); + const p2 = toMinimap(vx + viewportSize.width / viewport.k, vy + viewportSize.height / viewport.k); + context.globalAlpha = 1; + context.strokeStyle = theme.node.activeStroke; + context.strokeRect(p1.x, p1.y, Math.max(p2.x - p1.x, 4), Math.max(p2.y - p1.y, 4)); } diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx index e01a4ee2a..9e6806f3c 100644 --- a/web/src/components/canvas/canvas-node-content.tsx +++ b/web/src/components/canvas/canvas-node-content.tsx @@ -1,9 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode, type RefObject } from "react"; +import { idleTaskTickerSubscribe, subscribeTaskTicker, taskTickerSnapshot } from "@/lib/canvas/task-ticker"; import { AlertCircle, BookOpenCheck, Clock3, Download, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, Play, RefreshCw, Video } from "lucide-react"; import { VideoPlayer } from "@/components/video-player"; import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error"; import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel, isGenerationTaskSubmissionUncertain } from "@/lib/generation-task-display"; +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 { fitNodeSize } from "@/lib/canvas/canvas-node-size"; import { canvasTextFontSize } from "@/lib/canvas/canvas-text-scale"; @@ -15,7 +18,6 @@ import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-refer import type { CanvasTheme } from "@/lib/canvas-theme"; import { formatBytes } from "@/lib/image-utils"; import type { GenerationTask } from "@/services/api/task-center"; -import { scheduleResourceBlobCache } from "@/services/resource-blob-cache"; import { resolveMediaUrl } from "@/services/file-storage"; import { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; @@ -42,6 +44,7 @@ import { MEDIA_CONVERSION_NODE_TYPE } from "@/lib/media-conversion/contracts"; export type CanvasNodeContentProps = { node: CanvasNodeData; theme: CanvasTheme; + renderLOD?: CanvasNodeRenderLOD; isEditingContent: boolean; textareaRef: RefObject; isBatchRoot: boolean; @@ -65,14 +68,17 @@ export type CanvasNodeContentProps = { }; export function CanvasNodeContent(props: CanvasNodeContentProps) { + if (props.renderLOD === "shell") return ; + if (props.renderLOD === "preview") return ; if (props.node.metadata?.fileUpload) 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); + 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 ; @@ -86,20 +92,50 @@ export function CanvasNodeContent(props: CanvasNodeContentProps) { 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
- 插件节点等待隔离运行时 -
; + 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 || "插件节点"}} -
; + 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) { @@ -138,15 +174,17 @@ function DrawingContent({ node, theme, drawingProjectId }: CanvasNodeContentProp 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)); + 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); @@ -169,8 +207,12 @@ function DrawingContent({ node, theme, drawingProjectId }: CanvasNodeContentProp )}
-
{node.title || "绘图"}
-
{shapeCount} 个图形 · {pageCount} 个页面
+
+ {node.title || "绘图"} +
+
+ {shapeCount} 个图形 · {pageCount} 个页面 +
@@ -210,10 +252,23 @@ function LoadingContent({ node, theme, onOpenTaskDetails }: Pick ) : null}
- {elapsed} · {shortTaskId(taskId)} + + {elapsed} · {shortTaskId(taskId)}
- +
) : null} @@ -222,14 +277,11 @@ function LoadingContent({ node, theme, onOpenTaskDetails }: Pick { - if (!createdAt) return; - const timer = window.setInterval(() => setTick((value) => value + 1), 1000); - return () => window.clearInterval(timer); - }, [createdAt]); + const now = useSyncExternalStore(createdAt ? subscribeTaskTicker : idleTaskTickerSubscribe, taskTickerSnapshot, taskTickerSnapshot); if (!createdAt) return "刚刚"; - const seconds = Math.max(0, Math.floor((Date.now() - new Date(createdAt).getTime()) / 1000)); + const started = new Date(createdAt).getTime(); + if (!Number.isFinite(started)) return "刚刚"; + const seconds = Math.max(0, Math.floor((now - started) / 1000)); if (seconds < 60) return `${seconds}秒`; const minutes = Math.floor(seconds / 60); return minutes < 60 ? `${minutes}分${seconds % 60}秒` : `${Math.floor(minutes / 60)}时${minutes % 60}分`; @@ -253,7 +305,9 @@ function ErrorContent({ node, theme, onRetry, onReloadResource }: Pick -
{submissionUncertain ? generationTaskStatusLabel(errorDisplayTask) : generationErrorMessage(node.metadata?.errorDetails)}
+
+ {submissionUncertain ? generationTaskStatusLabel(errorDisplayTask) : generationErrorMessage(node.metadata?.errorDetails)} +
{submissionUncertain ? (
{generationTaskStageLabel(errorDisplayTask)} @@ -268,7 +322,10 @@ function ErrorContent({ node, theme, onRetry, onReloadResource }: Pick { event.stopPropagation(); onReloadResource?.(node); }} + onClick={(event) => { + event.stopPropagation(); + onReloadResource?.(node); + }} onMouseDown={(event) => event.stopPropagation()} > @@ -278,7 +335,10 @@ function ErrorContent({ node, theme, onRetry, onReloadResource }: Pick { event.stopPropagation(); onRetry?.(node); }} + onClick={(event) => { + event.stopPropagation(); + onRetry?.(node); + }} onMouseDown={(event) => event.stopPropagation()} > @@ -305,7 +365,11 @@ function ErrorContent({ node, theme, onRetry, onReloadResource }: Pick) { - return
未知节点
; + return ( +
+ 未知节点 +
+ ); } function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing }: CanvasNodeContentProps) { @@ -362,22 +426,45 @@ function SkillContent({ node, theme }: CanvasNodeContentProps) {
-
{skill?.name || node.title || "技能"}
+
+ {skill?.name || node.title || "技能"} +
- {skillCategoryLabel(skill?.category)}·{skillOutputModeLabel(skill?.outputMode)} - {skill?.version ? <>·v{skill.version} : null} + {skillCategoryLabel(skill?.category)} + · + {skillOutputModeLabel(skill?.outputMode)} + {skill?.version ? ( + <> + · + v{skill.version} + + ) : null}
- {skill?.description ?
{skill.description}
: null} + {skill?.description ? ( +
+ {skill.description} +
+ ) : null}
模板
{template || "未配置技能模板"}
- {tags.length ? tags.map((tag) => {tag}) : 连接到图片、视频、音频或文本节点后生效} + {tags.length ? ( + tags.map((tag) => ( + + {tag} + + )) + ) : ( + + 连接到图片、视频、音频或文本节点后生效 + + )}
); @@ -400,31 +487,69 @@ function skillOutputModeLabel(mode?: string) { 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}; + const content = + props.node.metadata?.status === "loading" ? ( + + ) : props.node.metadata?.status === "error" ? ( + + ) : ( + + ); + return ( + + {content} + + ); } if (!props.node.metadata?.content) return ; - return ; + return ( + + ); } function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchPreviewNodes, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: CanvasNodeContentProps) { const isCharacterReference = node.metadata?.workflowKind === "character" && node.metadata?.characterView === "multi"; const content = (
-
+
+ +
{isCharacterReference ? (
-
{node.metadata?.characterName || node.title}
+
+ {node.metadata?.characterName || node.title} +
多视角参考 · 待生成
- ) : 空图片节点} + ) : ( + 空图片节点 + )}
); - if (isBatchRoot) return {content}; + if (isBatchRoot) + return ( + + {content} + + ); return content; } @@ -432,10 +557,16 @@ function VideoNodeContent({ node, theme, mediaActive = false, onMediaPlayRequest const playerBoxRef = useRef(null); const { updateMediaNode } = useCanvasNodeActions(); const { url, loading } = useVideoPlaybackUrl(node, mediaActive); + const preview = canvasNodeVideoPreviewReference(node); 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); + const [videoReady, setVideoReady] = useState(false); + + useEffect(() => { + setVideoReady(false); + }, [url]); useEffect(() => { const box = playerBoxRef.current; @@ -461,7 +592,6 @@ function VideoNodeContent({ node, theme, mediaActive = false, onMediaPlayRequest if (!node.metadata?.content && !node.metadata?.storageKey) return } label="空视频节点" color={theme.node.placeholder} />; if (!mediaActive) return onMediaPlayRequest?.(node.id)} />; - if (!url) return } label={loading ? "正在加载视频" : "视频资源不可用"} />; 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)); @@ -471,10 +601,38 @@ function VideoNodeContent({ node, theme, mediaActive = false, onMediaPlayRequest return (
-
- scheduleResourceBlobCache(node.metadata?.storageKey || "")} /> - {activeEntry && activeEntry.text.trim() ? : null} -
+ {preview ? ( + } + fallback={
); } @@ -507,8 +665,6 @@ function InactiveVideoPreview({ node, theme, onPlay }: Pick { const element = previewRef.current; @@ -522,33 +678,12 @@ function InactiveVideoPreview({ node, theme, onPlay }: Pick () => { - if (localPreviewUrlRef.current) URL.revokeObjectURL(localPreviewUrlRef.current); - }, []); - - useEffect(() => { - if (hasPersistedPreview || localPreviewUrl || !nearViewport || (!node.metadata?.content && !node.metadata?.storageKey)) { - setPassiveVideoUrl(""); - setPassiveVideoReady(false); - return; - } - let cancelled = false; - const content = node.metadata?.content || ""; - const fallback = node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(content) : content; - setPassiveVideoUrl(""); - setPassiveVideoReady(false); - // This is a real