perf(canvas): 画布渲染 - 降低大画布节点开销
This commit is contained in:
@@ -9,6 +9,7 @@ import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-con
|
||||
import { sortCanvasNodesByStackOrder, type CanvasNodeStackOrder } from "@/lib/canvas/canvas-node-stack-order";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { isFrameNode } from "@/lib/canvas/canvas-frame";
|
||||
import type { CanvasNodeRenderLOD } from "@/lib/canvas/canvas-node-lod";
|
||||
import type { CanvasDisplayConnection, CanvasFolderStyle, CanvasFolderTheme, CanvasNodeData, ConnectionHandle, Position, SelectionBox } from "@/types/canvas";
|
||||
|
||||
type DragPreview = { x: number; y: number; nodeIds: Set<string> } | null;
|
||||
@@ -28,6 +29,7 @@ type CanvasProjectWorldLayersProps = {
|
||||
connectionApproach: CanvasConnectionApproach;
|
||||
nodeById: Map<string, CanvasNodeData>;
|
||||
visibleNodes: CanvasNodeData[];
|
||||
nodeRenderLODById: Map<string, CanvasNodeRenderLOD>;
|
||||
nodeStackOrder: CanvasNodeStackOrder;
|
||||
frameChildrenById: Map<string, CanvasNodeData[]>;
|
||||
linkedFolderPreviewNodesById: Map<string, CanvasNodeData[]>;
|
||||
@@ -136,6 +138,7 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
|
||||
<CanvasFrameNode
|
||||
key={node.id}
|
||||
data={node}
|
||||
renderLOD={props.nodeRenderLODById.get(node.id) || "full"}
|
||||
dragOffset={props.dragPreview?.nodeIds.has(node.id) ? props.dragPreview : undefined}
|
||||
childNodes={framePreviewNodes(node)}
|
||||
scale={viewportScale}
|
||||
@@ -153,6 +156,7 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
|
||||
<CanvasNode
|
||||
key={node.id}
|
||||
data={node}
|
||||
renderLOD={props.nodeRenderLODById.get(node.id) || "full"}
|
||||
dragOffset={props.dragPreview?.nodeIds.has(node.id) ? props.dragPreview : undefined}
|
||||
scale={viewportScale}
|
||||
isSelected={props.selectedNodeIds.has(node.id)}
|
||||
|
||||
@@ -1409,6 +1409,7 @@ function InfiniteCanvasPage() {
|
||||
imageEditNode,
|
||||
mentionReferencesByNodeId,
|
||||
nodeById,
|
||||
nodeRenderLODById,
|
||||
previewNode,
|
||||
reduceMediaEffects,
|
||||
relatedHighlight,
|
||||
@@ -2692,6 +2693,7 @@ function InfiniteCanvasPage() {
|
||||
connectionTargetNodeId={connectionTargetNodeId}
|
||||
nodeById={nodeById}
|
||||
visibleNodes={visibleNodes}
|
||||
nodeRenderLODById={nodeRenderLODById}
|
||||
nodeStackOrder={nodeStackOrder}
|
||||
frameChildrenById={frameChildrenById}
|
||||
linkedFolderPreviewNodesById={linkedFolderPreviewNodesById}
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { listGenerationTasks, type GenerationTask } from "@/services/api/task-center";
|
||||
import { listGenerationTasks, subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center";
|
||||
|
||||
export function useCanvasActiveTasks(projectId: string, enabled: boolean) {
|
||||
const queryClient = useQueryClient();
|
||||
const query = useQuery<GenerationTask[]>({
|
||||
queryKey: ["canvas-active-tasks", projectId],
|
||||
// Agent 的持久化执行仍复用任务队列/计费生命周期,但不应占据画布右上角的“生成任务”浮层。
|
||||
// 多取一页再过滤,避免 Agent 排在前面时把真正的画布生成任务挤掉。
|
||||
queryFn: () => listGenerationTasks(30, { projectId, activeOnly: true }).then((tasks) => tasks.filter((task) => !isInternalAgentTask(task)).slice(0, 5)),
|
||||
queryFn: ({ signal }) => listGenerationTasks(30, { projectId, activeOnly: true }, undefined, signal).then((tasks) => tasks.filter((task) => !isInternalAgentTask(task)).slice(0, 5)),
|
||||
enabled: enabled && Boolean(projectId),
|
||||
refetchInterval: (current) => (current.state.data?.length ? 2_000 : 10_000),
|
||||
// Shared task observers provide live updates. Lists only discover missed/new tasks.
|
||||
refetchInterval: 10_000,
|
||||
refetchIntervalInBackground: false,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const taskIds = JSON.stringify((query.data || []).map((task) => task.id).sort());
|
||||
useEffect(() => {
|
||||
if (!enabled || !projectId) return;
|
||||
return subscribeGenerationTasks(JSON.parse(taskIds) as string[], (task) => {
|
||||
if (task.projectId !== projectId || isInternalAgentTask(task)) return;
|
||||
queryClient.setQueryData<GenerationTask[]>(["canvas-active-tasks", projectId], (current) => {
|
||||
if (!current) return current;
|
||||
const previous = current.find((item) => item.id === task.id);
|
||||
if (!previous || previous.updatedAt > task.updatedAt) return current;
|
||||
if (task.status !== "queued" && task.status !== "running") return current.filter((item) => item.id !== task.id);
|
||||
return current.map((item) => item.id === task.id ? task : item);
|
||||
});
|
||||
});
|
||||
}, [enabled, projectId, queryClient, taskIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !projectId) return;
|
||||
const handleTaskChanged = (event: Event) => {
|
||||
const task = (event as CustomEvent<{ task?: GenerationTask }>).detail?.task;
|
||||
if (task?.projectId === projectId) void query.refetch();
|
||||
@@ -25,7 +44,7 @@ export function useCanvasActiveTasks(projectId: string, enabled: boolean) {
|
||||
window.removeEventListener("canvas:task-created", handleTaskChanged);
|
||||
window.removeEventListener("canvas:task-cancelled", handleTaskChanged);
|
||||
};
|
||||
}, [projectId, query.refetch]);
|
||||
}, [enabled, projectId, query.refetch]);
|
||||
|
||||
return {
|
||||
tasks: query.data || [],
|
||||
|
||||
@@ -254,12 +254,12 @@ export function useCanvasProjectLifecycle({
|
||||
useEffect(() => {
|
||||
if (!projectLoaded || historyPausedRef.current) return;
|
||||
const snapshot = { nodes, connections, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo };
|
||||
if (!observedContentRef.current || JSON.stringify(observedContentRef.current) === JSON.stringify(snapshot)) return;
|
||||
if (!observedContentRef.current || sameCanvasHistorySnapshot(observedContentRef.current, snapshot)) return;
|
||||
observedContentRef.current = snapshot;
|
||||
const patch = { nodes, connections, chatSessions, activeChatId, appearance: canvasAppearance, backgroundMode, showImageInfo };
|
||||
const stored = useCanvasStore.getState().projects.find((project) => project.id === projectId);
|
||||
// 远端结果投影到编辑器不是一次本地编辑,避免改写时间戳并触发反向保存。
|
||||
if (stored && Object.entries(patch).every(([key, value]) => JSON.stringify(stored[key as keyof CanvasProject]) === JSON.stringify(value))) return;
|
||||
if (stored && sameCanvasPatch(stored, patch)) return;
|
||||
updateProject(projectId, patch);
|
||||
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connections, historyPausedRef, nodes, projectId, projectLoaded, showImageInfo, updateProject]);
|
||||
|
||||
@@ -317,7 +317,7 @@ export function useCanvasProjectLifecycle({
|
||||
|
||||
const persistLocalEdits = useCallback(async () => {
|
||||
const snapshot = { nodes: nodesRef.current, connections: connectionsRef.current, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo };
|
||||
if (observedContentRef.current && JSON.stringify(observedContentRef.current) !== JSON.stringify(snapshot)) {
|
||||
if (observedContentRef.current && !sameCanvasHistorySnapshot(observedContentRef.current, snapshot)) {
|
||||
updateProject(projectId, {
|
||||
nodes: nodesRef.current,
|
||||
connections: connectionsRef.current,
|
||||
@@ -407,6 +407,26 @@ export function useCanvasProjectLifecycle({
|
||||
}
|
||||
|
||||
|
||||
function sameCanvasHistorySnapshot(left: CanvasHistorySnapshot, right: CanvasHistorySnapshot) {
|
||||
return left.nodes === right.nodes
|
||||
&& left.connections === right.connections
|
||||
&& left.chatSessions === right.chatSessions
|
||||
&& left.activeChatId === right.activeChatId
|
||||
&& left.canvasAppearance === right.canvasAppearance
|
||||
&& left.backgroundMode === right.backgroundMode
|
||||
&& left.showImageInfo === right.showImageInfo;
|
||||
}
|
||||
|
||||
function sameCanvasPatch(project: CanvasProject, patch: Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "showImageInfo"> & { appearance: CanvasProject["appearance"] }) {
|
||||
return project.nodes === patch.nodes
|
||||
&& project.connections === patch.connections
|
||||
&& project.chatSessions === patch.chatSessions
|
||||
&& project.activeChatId === patch.activeChatId
|
||||
&& project.appearance === patch.appearance
|
||||
&& project.backgroundMode === patch.backgroundMode
|
||||
&& project.showImageInfo === patch.showImageInfo;
|
||||
}
|
||||
|
||||
function mergeHydratedSessions(currentSessions: CanvasAssistantSession[], hydratedSessions: CanvasAssistantSession[]) {
|
||||
const hydratedById = new Map(hydratedSessions.map((session) => [session.id, session]));
|
||||
return currentSessions.map((session) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { buildNodeGenerationInputs, type NodeGenerationInput } from "@/components/canvas/canvas-node-generation";
|
||||
import { isFrameNode } from "@/lib/canvas/canvas-frame";
|
||||
@@ -7,7 +7,8 @@ import { canvasNodeRenderBudget, canvasNodeRenderPadding, CANVAS_MAX_RENDERED_CO
|
||||
import { buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences, buildToolMentionReference, parseToolMentionTokens } from "@/lib/canvas/canvas-resource-references";
|
||||
import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions";
|
||||
import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex, type CanvasSpatialIndexEntry } from "@/lib/canvas/canvas-spatial-index";
|
||||
import { selectCanvasVisibleNodes } from "@/lib/canvas/canvas-node-visibility";
|
||||
import { canvasOverviewMode, resolveCanvasNodeLOD, type CanvasNodeRenderLOD } from "@/lib/canvas/canvas-node-lod";
|
||||
import { intersectsCanvasBounds, selectCanvasVisibleNodes } from "@/lib/canvas/canvas-node-visibility";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import type { Asset, ImageAsset } from "@/stores/use-asset-store";
|
||||
import type { DirectorScene } from "@/types/director";
|
||||
@@ -176,7 +177,8 @@ export function useCanvasRenderModel({
|
||||
return index;
|
||||
}, [nodes]);
|
||||
const renderedNodeIdsRef = useRef<Set<string>>(new Set());
|
||||
const visibleNodes = useMemo(() => {
|
||||
const forcedRenderNodeIds = useMemo(() => new Set([...selectedNodeIds, ...(dragPreview?.nodeIds || [])]), [dragPreview, selectedNodeIds]);
|
||||
const candidateNodes = useMemo(() => {
|
||||
const frames: CanvasNodeData[] = [];
|
||||
const regular: CanvasNodeData[] = [];
|
||||
selectCanvasVisibleNodes({
|
||||
@@ -185,13 +187,74 @@ export function useCanvasRenderModel({
|
||||
...renderBounds,
|
||||
hiddenIds: renderHiddenNodeIds,
|
||||
retainedIds: renderedNodeIdsRef.current,
|
||||
forcedIds: new Set([...selectedNodeIds, ...(dragPreview?.nodeIds || [])]),
|
||||
forcedIds: forcedRenderNodeIds,
|
||||
budget: canvasNodeRenderBudget(viewport.k),
|
||||
}).forEach((node) => {
|
||||
(isFrameNode(node) ? frames : regular).push(node);
|
||||
});
|
||||
return [...frames, ...regular];
|
||||
}, [dragPreview, nodeById, nodeSpatialIndex, renderBounds, renderHiddenNodeIds, selectedNodeIds, viewport.k]);
|
||||
}, [forcedRenderNodeIds, nodeById, nodeSpatialIndex, renderBounds, renderHiddenNodeIds, viewport.k]);
|
||||
const immediateRenderNodeIds = useMemo(() => {
|
||||
const ids = new Set(forcedRenderNodeIds);
|
||||
candidateNodes.forEach((node) => {
|
||||
if (ids.has(node.id) || intersectsCanvasBounds(canvasNodeBounds(node), renderBounds.view)) ids.add(node.id);
|
||||
});
|
||||
return ids;
|
||||
}, [candidateNodes, forcedRenderNodeIds, renderBounds.view]);
|
||||
const [mountedNodeIds, setMountedNodeIds] = useState<Set<string>>(() => new Set());
|
||||
useEffect(() => {
|
||||
const candidateIds = new Set(candidateNodes.map((node) => node.id));
|
||||
setMountedNodeIds((current) => {
|
||||
const next = new Set<string>();
|
||||
current.forEach((id) => {
|
||||
if (candidateIds.has(id)) next.add(id);
|
||||
});
|
||||
immediateRenderNodeIds.forEach((id) => next.add(id));
|
||||
return next.size === current.size && [...next].every((id) => current.has(id)) ? current : next;
|
||||
});
|
||||
const queue = candidateNodes
|
||||
.filter((node) => !immediateRenderNodeIds.has(node.id))
|
||||
.sort((left, right) => {
|
||||
const leftDistance = distanceToBounds(canvasNodeBounds(left), renderBounds.view);
|
||||
const rightDistance = distanceToBounds(canvasNodeBounds(right), renderBounds.view);
|
||||
return leftDistance - rightDistance;
|
||||
});
|
||||
let cursor = 0;
|
||||
let frameId = 0;
|
||||
const schedule = () => {
|
||||
frameId = requestAnimationFrame(() => {
|
||||
const startedAt = performance.now();
|
||||
const additions: string[] = [];
|
||||
while (cursor < queue.length && additions.length < 12 && performance.now() - startedAt < 4) additions.push(queue[cursor++].id);
|
||||
if (additions.length) {
|
||||
setMountedNodeIds((current) => {
|
||||
if (additions.every((id) => current.has(id))) return current;
|
||||
const next = new Set(current);
|
||||
additions.forEach((id) => next.add(id));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
if (cursor < queue.length) schedule();
|
||||
});
|
||||
};
|
||||
if (queue.length && typeof window !== "undefined") schedule();
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, [candidateNodes, immediateRenderNodeIds, renderBounds.view]);
|
||||
const visibleNodes = useMemo(() => candidateNodes.filter((node) => immediateRenderNodeIds.has(node.id) || mountedNodeIds.has(node.id)), [candidateNodes, immediateRenderNodeIds, mountedNodeIds]);
|
||||
const overviewRef = useRef(false);
|
||||
const overview = canvasOverviewMode(viewport.k, overviewRef.current);
|
||||
useEffect(() => { overviewRef.current = overview; }, [overview]);
|
||||
const nodeRenderLODById = useMemo(() => {
|
||||
const result = new Map<string, CanvasNodeRenderLOD>();
|
||||
for (const node of visibleNodes) {
|
||||
// Selection/drag keeps every node visible, not every editor mounted.
|
||||
const editing = selectedNodeIds.size === 1 && selectedNodeIds.has(node.id);
|
||||
const inView = intersectsCanvasBounds(canvasNodeBounds(node), renderBounds.view);
|
||||
const inEntry = intersectsCanvasBounds(canvasNodeBounds(node), renderBounds.enter);
|
||||
result.set(node.id, resolveCanvasNodeLOD(overview, inView, inEntry, editing));
|
||||
}
|
||||
return result;
|
||||
}, [overview, selectedNodeIds, renderBounds.enter, renderBounds.view, visibleNodes]);
|
||||
useEffect(() => {
|
||||
renderedNodeIdsRef.current = new Set(visibleNodes.map((node) => node.id));
|
||||
}, [visibleNodes]);
|
||||
@@ -397,6 +460,7 @@ export function useCanvasRenderModel({
|
||||
imageEditNode,
|
||||
mentionReferencesByNodeId,
|
||||
nodeById,
|
||||
nodeRenderLODById,
|
||||
previewNode,
|
||||
reduceMediaEffects,
|
||||
relatedHighlight,
|
||||
@@ -413,3 +477,9 @@ export function useCanvasRenderModel({
|
||||
visibleNodes,
|
||||
};
|
||||
}
|
||||
|
||||
function distanceToBounds(node: { left: number; top: number; right: number; bottom: number }, bounds: { left: number; top: number; right: number; bottom: number }) {
|
||||
const dx = node.right < bounds.left ? bounds.left - node.right : node.left > bounds.right ? node.left - bounds.right : 0;
|
||||
const dy = node.bottom < bounds.top ? bounds.top - node.bottom : node.top > bounds.bottom ? node.top - bounds.bottom : 0;
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user