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

294 lines
16 KiB
TypeScript

import { useMemo, useRef } 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 { shouldReduceCanvasMediaEffects } from "@/lib/canvas/canvas-performance-mode";
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions";
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 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[];
toolbarNodeId: string | null;
infoNodeId: string | null;
cropNodeId: string | null;
maskEditNodeId: string | null;
annotationNodeId: string | null;
splitNodeId: string | null;
upscaleNodeId: string | null;
superResolveNodeId: string | null;
angleNodeId: 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,
toolbarNodeId,
infoNodeId,
cropNodeId,
maskEditNodeId,
annotationNodeId,
splitNodeId,
upscaleNodeId,
superResolveNodeId,
angleNodeId,
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]);
const collapsedBatchChildIds = useMemo(() => {
const hidden = new Set<string>();
nodes.forEach((node) => {
const rootId = node.metadata?.batchRootId;
if (!rootId) return;
const root = nodeById.get(rootId);
if (root && !root.metadata?.imageBatchExpanded) hidden.add(node.id);
});
return hidden;
}, [nodeById, nodes]);
const renderHiddenNodeIds = useMemo(() => {
const hidden = new Set(collapsedBatchChildIds);
nodes.forEach((node) => {
const rootId = node.metadata?.batchRootId;
if (rootId && collapsingBatchIds.has(rootId)) hidden.delete(node.id);
const parent = node.parentId ? nodeById.get(node.parentId) : null;
if (parent && isFrameNode(parent) && parent.metadata?.frame?.collapsed) hidden.add(node.id);
});
return hidden;
}, [collapsedBatchChildIds, collapsingBatchIds, nodeById, nodes]);
const connectionLayerBounds = useMemo(() => {
if (!nodes.length) return { left: -1, top: -1, width: 2, height: 2 };
const padding = 320;
let left = Number.POSITIVE_INFINITY;
let top = Number.POSITIVE_INFINITY;
let right = Number.NEGATIVE_INFINITY;
let bottom = Number.NEGATIVE_INFINITY;
nodes.forEach((node) => {
left = Math.min(left, node.position.x - padding);
top = Math.min(top, node.position.y - padding);
right = Math.max(right, node.position.x + node.width + padding);
bottom = Math.max(bottom, node.position.y + node.height + padding);
});
return { left, top, width: Math.max(2, right - left), height: Math.max(2, bottom - top) };
}, [nodes]);
const visibleNodes = useMemo(() => {
const padding = (reduceMediaEffects ? Math.max(240, Math.max(viewportSize.width, viewportSize.height) * 0.4) : Math.max(800, Math.max(viewportSize.width, viewportSize.height) * 1.5)) / viewport.k;
const viewLeft = -viewport.x / viewport.k - padding;
const viewTop = -viewport.y / viewport.k - padding;
const viewRight = viewLeft + viewportSize.width / viewport.k + padding * 2;
const viewBottom = viewTop + viewportSize.height / viewport.k + padding * 2;
const frames: CanvasNodeData[] = [];
const regular: CanvasNodeData[] = [];
nodes.forEach((node) => {
if (renderHiddenNodeIds.has(node.id) || node.position.x + node.width <= viewLeft || node.position.x >= viewRight || node.position.y + node.height <= viewTop || node.position.y >= viewBottom) return;
(isFrameNode(node) ? frames : regular).push(node);
});
return [...frames, ...regular];
}, [nodes, reduceMediaEffects, renderHiddenNodeIds, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image"), [assets]);
const canvasImageNodes = useMemo(() => nodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(node.metadata?.content) && !collapsedBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed)), [collapsedBatchChildIds, nodeById, nodes]);
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 toolbarCandidate = toolbarNodeId ? nodeById.get(toolbarNodeId) || 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 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 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;
const activeNodeId = selectedNodeIds.size > 1 ? null : hoveredNodeId || (selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null);
const selectedNodeBounds = useMemo(() => {
if (selectedNodeIds.size < 2) return null;
const selectedNodes = nodes.filter((node) => selectedNodeIds.has(node.id) && !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 };
}, [nodes, renderHiddenNodeIds, selectedNodeIds]);
const selectedVideoNodes = useMemo(() => nodes
.filter((node) => selectedNodeIds.has(node.id) && node.type === CanvasNodeType.Video && Boolean(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;
}), [nodes, renderHiddenNodeIds, selectedNodeIds]);
const batchChildCountById = useMemo(() => {
const map = new Map<string, number>();
nodes.forEach((node) => {
if (!node.metadata?.isBatchRoot) return;
const liveChildCount = (node.metadata.batchChildIds || []).filter((childId) => nodeById.get(childId)?.metadata?.batchRootId === node.id).length;
map.set(node.id, liveChildCount);
});
return map;
}, [nodeById, nodes]);
const frameChildrenById = useMemo(() => {
const map = new Map<string, CanvasNodeData[]>();
nodes.forEach((node) => {
if (!node.parentId) return;
const children = map.get(node.parentId) || [];
children.push(node);
map.set(node.parentId, children);
});
return map;
}, [nodes]);
const batchMotionById = useMemo(() => {
const map = new Map<string, { x: number; y: number; index: number }>();
nodes.forEach((node) => {
const rootId = node.metadata?.batchRootId;
if (!rootId) return;
const root = nodeById.get(rootId);
const index = root?.metadata?.batchChildIds?.indexOf(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;
map.set(node.id, { x: stackX - node.position.x, y: stackY - node.position.y, index: Math.max(index, 0) });
});
return map;
}, [nodeById, nodes]);
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 displayConnections = useMemo(() => connections.flatMap((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 from = dragPreview?.nodeIds.has(displayFrom.id) ? { ...displayFrom, position: { x: displayFrom.position.x + dragPreview.x, y: displayFrom.position.y + dragPreview.y } } : displayFrom;
const to = dragPreview?.nodeIds.has(displayTo.id) ? { ...displayTo, position: { x: displayTo.position.x + dragPreview.x, y: displayTo.position.y + dragPreview.y } } : displayTo;
return [{ connection, from, to }];
}), [collapsedBatchChildIds, connections, dragPreview, nodeById]);
const configInputsById = useMemo(() => {
const map = new Map<string, NodeGenerationInput[]>();
semanticNodes.forEach((node) => {
if (node.type === CanvasNodeType.Config) map.set(node.id, buildNodeGenerationInputs(node.id, semanticNodes, connections));
});
return map;
}, [connections, semanticNodes]);
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 canvasResourceReferences = useMemo(() => buildCanvasResourceReferences(semanticNodes, connections, dialogNodeId || activeNodeId), [activeNodeId, connections, dialogNodeId, semanticNodes]);
const resourceReferenceByNodeId = useMemo(() => new Map(canvasResourceReferences.map((reference) => [reference.nodeId, reference])), [canvasResourceReferences]);
const skillMentionReferences = useMemo(() => buildSkillMentionReferences(addedSkills), [addedSkills]);
const mentionReferencesByNodeId = useMemo(() => {
const map = new Map<string, ReturnType<typeof buildNodeMentionReferences>>();
semanticNodes.forEach((node) => map.set(node.id, [...buildNodeMentionReferences(node, semanticNodes, connections), ...skillMentionReferences]));
return map;
}, [connections, semanticNodes, skillMentionReferences]);
return {
activeDirectorNode,
activeDirectorScene,
activeNodeId,
activeScriptNode,
activeStylePresetId,
angleNode,
emotionNode,
annotationNode,
batchChildCountById,
batchMotionById,
canvasImageNodes,
configInputsById,
connectionLayerBounds,
contextMenuNode,
cropNode,
displayConnections,
frameChildrenById,
imageAssets,
infoNode,
maskEditNode,
mentionReferencesByNodeId,
nodeById,
previewNode,
reduceMediaEffects,
relatedHighlight,
resourceReferenceByNodeId,
selectedNodeBounds,
selectedVideoNodes,
semanticNodes,
skillMentionReferences,
splitNode,
superResolveNode,
toolbarNode,
upscaleNode,
versionCompareNodes,
visibleNodes,
};
}