Files
ttstd-infinite-canvas/web/src/pages/canvas/use-canvas-render-model.ts

487 lines
28 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { buildNodeGenerationInputs, type NodeGenerationInput } from "@/components/canvas/canvas-node-generation";
import { isFrameNode } from "@/lib/canvas/canvas-frame";
import { sameNodeSemanticData } from "@/lib/canvas/canvas-project-domain";
import { canvasNodeRenderBudget, canvasNodeRenderPadding, CANVAS_MAX_RENDERED_CONNECTIONS, shouldReduceCanvasMediaEffects } from "@/lib/canvas/canvas-performance-mode";
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 { canvasOverviewMode, resolveCanvasNodeLOD, type CanvasNodeRenderLOD } from "@/lib/canvas/canvas-node-lod";
import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract";
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";
import { CanvasNodeType, type CanvasConnection, type CanvasDisplayConnection, type CanvasMediaPerformanceMode, type CanvasNodeData, type ContextMenuState, type ViewportTransform } from "@/types/canvas";
type DragPreview = { x: number; y: number; nodeIds: Set<string> } | null;
type UseCanvasRenderModelOptions = {
nodes: CanvasNodeData[];
connections: CanvasConnection[];
assets: Asset[];
viewport: ViewportTransform;
viewportSize: { width: number; height: number };
mediaPerformanceMode: CanvasMediaPerformanceMode;
selectedNodeIds: Set<string>;
hoveredNodeId: string | null;
dragPreview: DragPreview;
collapsingBatchIds: Set<string>;
addedSkills: Skill[];
directorScenes?: DirectorScene[];
infoNodeId: string | null;
cropNodeId: string | null;
maskEditNodeId: string | null;
imageEditNodeId: string | null;
annotationNodeId: string | null;
splitNodeId: string | null;
upscaleNodeId: string | null;
superResolveNodeId: string | null;
angleNodeId: string | null;
lightingNodeId: string | null;
emotionNodeId: string | null;
previewNodeId: string | null;
contextMenu: ContextMenuState | null;
versionCompareRootId: string | null;
directorNodeId: string | null;
scriptEditorNodeId: string | null;
dialogNodeId: string | null;
};
export function useCanvasRenderModel({
nodes,
connections,
assets,
viewport,
viewportSize,
mediaPerformanceMode,
selectedNodeIds,
hoveredNodeId,
dragPreview,
collapsingBatchIds,
addedSkills,
directorScenes,
infoNodeId,
cropNodeId,
maskEditNodeId,
imageEditNodeId,
annotationNodeId,
splitNodeId,
upscaleNodeId,
superResolveNodeId,
angleNodeId,
lightingNodeId,
emotionNodeId,
previewNodeId,
contextMenu,
versionCompareRootId,
directorNodeId,
scriptEditorNodeId,
dialogNodeId,
}: UseCanvasRenderModelOptions) {
const reduceMediaEffects = useMemo(() => shouldReduceCanvasMediaEffects(mediaPerformanceMode, nodes), [mediaPerformanceMode, nodes]);
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
// These maps are consumed by both virtualization and node chrome. Building
// them together keeps a metadata update from walking a 50k-node array six
// separate times before React can paint the next frame.
const nodeDerivedData = useMemo(() => {
const collapsedBatchChildIds = new Set<string>();
const renderHiddenNodeIds = new Set<string>();
const frameChildrenById = new Map<string, CanvasNodeData[]>();
const canvasImageNodes: CanvasNodeData[] = [];
const batchRoots: CanvasNodeData[] = [];
const batchMotionById = new Map<string, { x: number; y: number; index: number }>();
const batchChildIndexByRootId = new Map<string, Map<string, number>>();
for (const node of nodes) {
const rootId = node.metadata?.batchRootId;
const root = rootId ? nodeById.get(rootId) : undefined;
if (root && !root.metadata?.imageBatchExpanded) {
collapsedBatchChildIds.add(node.id);
if (!collapsingBatchIds.has(root.id)) renderHiddenNodeIds.add(node.id);
}
if (rootId && collapsingBatchIds.has(rootId)) renderHiddenNodeIds.delete(node.id);
const parent = node.parentId ? nodeById.get(node.parentId) : undefined;
if (parent && isFrameNode(parent)) {
const children = frameChildrenById.get(parent.id);
if (children) children.push(node);
else frameChildrenById.set(parent.id, [node]);
if (parent.metadata?.frame?.collapsed) renderHiddenNodeIds.add(node.id);
}
if (node.metadata?.isBatchRoot) batchRoots.push(node);
if (node.type === CanvasNodeType.Image && node.metadata?.content && !collapsedBatchChildIds.has(node.id) && !(parent && isFrameNode(parent) && parent.metadata?.frame?.collapsed)) {
canvasImageNodes.push(node);
}
}
for (const root of batchRoots) {
const childIndex = new Map((root.metadata?.batchChildIds || []).map((childId, index) => [childId, index]));
batchChildIndexByRootId.set(root.id, childIndex);
}
for (const node of nodes) {
const rootId = node.metadata?.batchRootId;
if (!rootId) continue;
const root = nodeById.get(rootId);
const index = batchChildIndexByRootId.get(rootId)?.get(node.id) ?? 0;
const stackX = root ? root.position.x + 34 + index * 14 : node.position.x;
const stackY = root ? root.position.y + 14 + index * 8 : node.position.y;
batchMotionById.set(node.id, { x: stackX - node.position.x, y: stackY - node.position.y, index: Math.max(index, 0) });
}
const batchChildCountById = new Map<string, number>();
for (const root of batchRoots) {
const childIndex = batchChildIndexByRootId.get(root.id);
const liveChildCount = [...(childIndex?.keys() || [])].filter((childId) => nodeById.get(childId)?.metadata?.batchRootId === root.id).length;
batchChildCountById.set(root.id, liveChildCount);
}
return { batchChildCountById, batchMotionById, canvasImageNodes, collapsedBatchChildIds, frameChildrenById, renderHiddenNodeIds };
}, [collapsingBatchIds, nodeById, nodes]);
const { batchChildCountById, batchMotionById, canvasImageNodes, collapsedBatchChildIds, frameChildrenById, renderHiddenNodeIds } = nodeDerivedData;
const connectionLayerBounds = useMemo(() => {
const padding = (reduceMediaEffects ? 96 : 144) / Math.max(viewport.k, 0.05);
const left = -viewport.x / viewport.k - padding;
const top = -viewport.y / viewport.k - padding;
const width = viewportSize.width / viewport.k + padding * 2;
const height = viewportSize.height / viewport.k + padding * 2;
return { left, top, width: Math.max(2, width), height: Math.max(2, height) };
}, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
const renderBounds = useMemo(() => {
const enterPadding = canvasNodeRenderPadding(reduceMediaEffects, false) / viewport.k;
const retainPadding = canvasNodeRenderPadding(reduceMediaEffects, true) / viewport.k;
const viewLeft = -viewport.x / viewport.k;
const viewTop = -viewport.y / viewport.k;
const viewWidth = viewportSize.width / viewport.k;
const viewHeight = viewportSize.height / viewport.k;
return {
view: { left: viewLeft, top: viewTop, right: viewLeft + viewWidth, bottom: viewTop + viewHeight },
enter: { left: viewLeft - enterPadding, top: viewTop - enterPadding, right: viewLeft + viewWidth + enterPadding, bottom: viewTop + viewHeight + enterPadding },
retain: { left: viewLeft - retainPadding, top: viewTop - retainPadding, right: viewLeft + viewWidth + retainPadding, bottom: viewTop + viewHeight + retainPadding },
};
}, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
const nodeSpatialIndexRef = useRef<{ source: CanvasNodeData[]; index: CanvasSpatialIndex<string> } | null>(null);
const nodeSpatialIndex = useMemo(() => {
const previous = nodeSpatialIndexRef.current;
const geometryUnchanged =
previous &&
previous.source.length === nodes.length &&
nodes.every((node, index) => {
const old = previous.source[index];
return old.id === node.id && old.position.x === node.position.x && old.position.y === node.position.y && old.width === node.width && old.height === node.height;
});
if (geometryUnchanged) return previous.index;
const index = buildCanvasSpatialIndex(nodes.map((node) => ({ id: node.id, bounds: canvasNodeBounds(node), value: node.id })));
nodeSpatialIndexRef.current = { source: nodes, index };
return index;
}, [nodes]);
const renderedNodeIdsRef = useRef<Set<string>>(new Set());
const forcedRenderNodeIds = useMemo(() => new Set([...selectedNodeIds, ...(dragPreview?.nodeIds || [])]), [dragPreview, selectedNodeIds]);
const candidateNodes = useMemo(() => {
const frames: CanvasNodeData[] = [];
const regular: CanvasNodeData[] = [];
selectCanvasVisibleNodes({
index: nodeSpatialIndex,
nodeById,
...renderBounds,
hiddenIds: renderHiddenNodeIds,
retainedIds: renderedNodeIdsRef.current,
forcedIds: forcedRenderNodeIds,
budget: canvasNodeRenderBudget(viewport.k),
}).forEach((node) => {
(isFrameNode(node) ? frames : regular).push(node);
});
return [...frames, ...regular];
}, [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]);
const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image" && asset.status !== "archived"), [assets]);
const semanticNodesRef = useRef(nodes);
const semanticNodes = useMemo(() => {
const previous = semanticNodesRef.current;
const positionOnlyChange = previous.length === nodes.length && nodes.every((node, index) => sameNodeSemanticData(node, previous[index]));
if (!positionOnlyChange) semanticNodesRef.current = nodes;
return semanticNodesRef.current;
}, [nodes]);
const versionCompareNodes = useMemo(() => {
if (!versionCompareRootId) return [];
return nodes.filter((node) => (node.metadata?.versionOfNodeId || node.id) === versionCompareRootId).sort((a, b) => (a.metadata?.versionLabel || "").localeCompare(b.metadata?.versionLabel || ""));
}, [nodes, versionCompareRootId]);
const selectedNodeIdForToolbar = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null;
const toolbarCandidate = selectedNodeIdForToolbar ? nodeById.get(selectedNodeIdForToolbar) || null : null;
const toolbarNode = isFrameNode(toolbarCandidate) ? null : toolbarCandidate;
const infoNode = infoNodeId ? nodeById.get(infoNodeId) || null : null;
const cropNode = cropNodeId ? nodeById.get(cropNodeId) || null : null;
const maskEditNode = maskEditNodeId ? nodeById.get(maskEditNodeId) || null : null;
const imageEditNode = imageEditNodeId ? nodeById.get(imageEditNodeId) || null : null;
const annotationNode = annotationNodeId ? nodeById.get(annotationNodeId) || null : null;
const splitNode = splitNodeId ? nodeById.get(splitNodeId) || null : null;
const upscaleNode = upscaleNodeId ? nodeById.get(upscaleNodeId) || null : null;
const superResolveNode = superResolveNodeId ? nodeById.get(superResolveNodeId) || null : null;
const angleNode = angleNodeId ? nodeById.get(angleNodeId) || null : null;
const lightingNode = lightingNodeId ? nodeById.get(lightingNodeId) || null : null;
const emotionNode = emotionNodeId ? nodeById.get(emotionNodeId) || null : null;
const previewNode = previewNodeId ? nodeById.get(previewNodeId) || null : null;
const contextMenuNode = contextMenu?.type === "node" ? nodeById.get(contextMenu.nodeId) || null : null;
// Hover only drives transient affordances (toolbar/handles). Selection is
// the explicit focus action that raises a node and highlights its graph.
const activeNodeId = selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null;
const selectedNodeBounds = useMemo(() => {
if (selectedNodeIds.size < 2) return null;
const selectedNodes = [...selectedNodeIds].map((nodeId) => nodeById.get(nodeId)).filter((node): node is CanvasNodeData => Boolean(node && !renderHiddenNodeIds.has(node.id)));
if (selectedNodes.length < 2) return null;
const left = Math.min(...selectedNodes.map((node) => node.position.x));
const top = Math.min(...selectedNodes.map((node) => node.position.y));
const right = Math.max(...selectedNodes.map((node) => node.position.x + node.width));
const bottom = Math.max(...selectedNodes.map((node) => node.position.y + node.height));
return { left, top, width: right - left, height: bottom - top, count: selectedNodes.length };
}, [nodeById, renderHiddenNodeIds, selectedNodeIds]);
const selectedVideoNodes = useMemo(
() =>
[...selectedNodeIds]
.map((nodeId) => nodeById.get(nodeId))
.filter((node): node is CanvasNodeData => Boolean(node && node.type === CanvasNodeType.Video && node.metadata?.content && !renderHiddenNodeIds.has(node.id)))
.sort((a, b) => {
const shotA = a.metadata?.shotIndex ?? Number.MAX_SAFE_INTEGER;
const shotB = b.metadata?.shotIndex ?? Number.MAX_SAFE_INTEGER;
return shotA - shotB || a.position.y - b.position.y || a.position.x - b.position.x;
}),
[nodeById, renderHiddenNodeIds, selectedNodeIds],
);
const relatedHighlight = useMemo(() => {
const nodeIds = new Set<string>();
const connectionIds = new Set<string>();
if (!activeNodeId) return { nodeIds, connectionIds };
nodeIds.add(activeNodeId);
connections.forEach((connection) => {
if (connection.fromNodeId !== activeNodeId && connection.toNodeId !== activeNodeId) return;
connectionIds.add(connection.id);
nodeIds.add(connection.fromNodeId);
nodeIds.add(connection.toNodeId);
});
return { nodeIds, connectionIds };
}, [activeNodeId, connections]);
const connectionSpatialIndex = useMemo(() => {
const entries: CanvasSpatialIndexEntry<CanvasDisplayConnection>[] = [];
const connectionIdsByNodeId = new Map<string, Set<string>>();
connections.forEach((connection) => {
if (collapsedBatchChildIds.has(connection.fromNodeId) || collapsedBatchChildIds.has(connection.toNodeId)) return;
const fromNode = nodeById.get(connection.fromNodeId);
const toNode = nodeById.get(connection.toNodeId);
if (!fromNode || !toNode) return;
const fromParent = fromNode.parentId ? nodeById.get(fromNode.parentId) : null;
const toParent = toNode.parentId ? nodeById.get(toNode.parentId) : null;
const displayFrom = fromParent && isFrameNode(fromParent) && fromParent.metadata?.frame?.collapsed ? fromParent : fromNode;
const displayTo = toParent && isFrameNode(toParent) && toParent.metadata?.frame?.collapsed ? toParent : toNode;
if (displayFrom.id === displayTo.id) return;
const left = Math.min(displayFrom.position.x, displayTo.position.x);
const top = Math.min(displayFrom.position.y, displayTo.position.y);
const right = Math.max(displayFrom.position.x + displayFrom.width, displayTo.position.x + displayTo.width);
const bottom = Math.max(displayFrom.position.y + displayFrom.height, displayTo.position.y + displayTo.height);
const value = { connection, from: displayFrom, to: displayTo };
entries.push({ id: connection.id, bounds: { left, top, right, bottom }, value });
for (const nodeId of new Set([connection.fromNodeId, connection.toNodeId, displayFrom.id, displayTo.id])) {
const ids = connectionIdsByNodeId.get(nodeId) || new Set<string>();
ids.add(connection.id);
connectionIdsByNodeId.set(nodeId, ids);
}
});
return { index: buildCanvasSpatialIndex(entries), connectionIdsByNodeId, entriesById: new Map(entries.map((entry) => [entry.id, entry.value])) };
}, [collapsedBatchChildIds, connections, nodeById]);
const displayConnections = useMemo(() => {
const candidateById = new Map<string, CanvasDisplayConnection>();
connectionSpatialIndex.index.query(renderBounds.retain, CANVAS_MAX_RENDERED_CONNECTIONS).forEach((display) => candidateById.set(display.connection.id, display));
dragPreview?.nodeIds.forEach((nodeId) => {
connectionSpatialIndex.connectionIdsByNodeId.get(nodeId)?.forEach((connectionId) => {
const display = connectionSpatialIndex.entriesById.get(connectionId);
if (display) candidateById.set(connectionId, display);
});
});
return [...candidateById.values()].flatMap(({ connection, from: sourceFrom, to: sourceTo }) => {
const from = dragPreview?.nodeIds.has(sourceFrom.id) ? { ...sourceFrom, position: { x: sourceFrom.position.x + dragPreview.x, y: sourceFrom.position.y + dragPreview.y } } : sourceFrom;
const to = dragPreview?.nodeIds.has(sourceTo.id) ? { ...sourceTo, position: { x: sourceTo.position.x + dragPreview.x, y: sourceTo.position.y + dragPreview.y } } : sourceTo;
const connectionLeft = Math.min(from.position.x, to.position.x);
const connectionTop = Math.min(from.position.y, to.position.y);
const connectionRight = Math.max(from.position.x + from.width, to.position.x + to.width);
const connectionBottom = Math.max(from.position.y + from.height, to.position.y + to.height);
if (connectionRight <= renderBounds.retain.left || connectionLeft >= renderBounds.retain.right || connectionBottom <= renderBounds.retain.top || connectionTop >= renderBounds.retain.bottom) return [];
return [{ connection, from, to }];
});
}, [connectionSpatialIndex, dragPreview, renderBounds]);
const configInputsById = useMemo(() => {
const map = new Map<string, NodeGenerationInput[]>();
const configNodeIds = new Set<string>();
visibleNodes.forEach((node) => {
if (node.type === CanvasNodeType.Config) configNodeIds.add(node.id);
});
selectedNodeIds.forEach((nodeId) => {
if (nodeById.get(nodeId)?.type === CanvasNodeType.Config) configNodeIds.add(nodeId);
});
if (dialogNodeId && nodeById.get(dialogNodeId)?.type === CanvasNodeType.Config) configNodeIds.add(dialogNodeId);
configNodeIds.forEach((nodeId) => map.set(nodeId, buildNodeGenerationInputs(nodeId, semanticNodes, connections)));
return map;
}, [connections, dialogNodeId, nodeById, selectedNodeIds, semanticNodes, visibleNodes]);
const activeDirectorNode = useMemo(() => semanticNodes.find((node) => node.id === directorNodeId) || null, [directorNodeId, semanticNodes]);
const activeStylePresetId = useMemo(() => semanticNodes.find((node) => node.metadata?.workflowKind === "styleboard")?.metadata?.stylePresetId, [semanticNodes]);
const activeScriptNode = useMemo(() => semanticNodes.find((node) => node.id === scriptEditorNodeId && node.type === CanvasNodeType.Script) || null, [scriptEditorNodeId, semanticNodes]);
const activeDirectorScene = useMemo(() => directorScenes?.find((scene) => scene.id === activeDirectorNode?.metadata?.directorSceneId) || null, [activeDirectorNode?.metadata?.directorSceneId, directorScenes]);
const resourceReferenceTargetNodes = useMemo(() => {
const targetNodes = [...visibleNodes];
const activeId = dialogNodeId || activeNodeId;
if (activeId) {
const activeNode = nodeById.get(activeId);
if (activeNode) targetNodes.push(activeNode);
}
return targetNodes;
}, [activeNodeId, dialogNodeId, nodeById, visibleNodes]);
const canvasResourceReferences = useMemo(
() => buildCanvasResourceReferences(semanticNodes, connections, dialogNodeId || activeNodeId, resourceReferenceTargetNodes),
[activeNodeId, connections, dialogNodeId, resourceReferenceTargetNodes, semanticNodes],
);
const resourceReferenceByNodeId = useMemo(() => new Map(canvasResourceReferences.map((reference) => [reference.nodeId, reference])), [canvasResourceReferences]);
const skillMentionReferences = useMemo(() => buildSkillMentionReferences(addedSkills), [addedSkills]);
const toolMentionReferencesByNodeId = useMemo(() => {
const map = new Map<string, ReturnType<typeof buildToolMentionReference>[]>();
for (const node of semanticNodes) {
const text = nodeGenerationPrompt(node);
const tokens = parseToolMentionTokens(text);
if (!tokens.length) continue;
const seen = new Set<string>();
const refs: ReturnType<typeof buildToolMentionReference>[] = [];
for (const { type, toolId, label, icon } of tokens) {
const identity = `${type}:${toolId}`;
if (seen.has(identity)) continue;
seen.add(identity);
refs.push(buildToolMentionReference(toolId, label, type, icon));
}
if (refs.length) map.set(node.id, refs);
}
return map;
}, [semanticNodes]);
const mentionReferencesByNodeId = useMemo(() => {
const map = buildCanvasNodeMentionReferenceMap(semanticNodes, connections, visibleNodes);
if (!skillMentionReferences.length && toolMentionReferencesByNodeId.size === 0) return map;
map.forEach((references, nodeId) => {
const extras = [...skillMentionReferences, ...(toolMentionReferencesByNodeId.get(nodeId) ?? [])];
if (extras.length) map.set(nodeId, [...references, ...extras]);
});
return map;
}, [connections, semanticNodes, skillMentionReferences, toolMentionReferencesByNodeId, visibleNodes]);
return {
activeDirectorNode,
activeDirectorScene,
activeNodeId,
activeScriptNode,
activeStylePresetId,
angleNode,
lightingNode,
emotionNode,
annotationNode,
batchChildCountById,
batchMotionById,
canvasImageNodes,
configInputsById,
connectionLayerBounds,
contextMenuNode,
cropNode,
displayConnections,
frameChildrenById,
imageAssets,
infoNode,
maskEditNode,
imageEditNode,
mentionReferencesByNodeId,
nodeById,
nodeRenderLODById,
previewNode,
reduceMediaEffects,
relatedHighlight,
resourceReferenceByNodeId,
selectedNodeBounds,
selectedVideoNodes,
semanticNodes,
skillMentionReferences,
splitNode,
superResolveNode,
toolbarNode,
upscaleNode,
versionCompareNodes,
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);
}