pynode-flow 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. pynode/__init__.py +37 -0
  2. pynode/__main__.py +9 -0
  3. pynode/_perf.py +65 -0
  4. pynode/_version.py +24 -0
  5. pynode/api/__init__.py +15 -0
  6. pynode/api/helpers.py +79 -0
  7. pynode/api/nodes.py +423 -0
  8. pynode/api/services.py +124 -0
  9. pynode/api/sse.py +58 -0
  10. pynode/api/uploads.py +59 -0
  11. pynode/api/workflows.py +466 -0
  12. pynode/config.py +93 -0
  13. pynode/install_nodes.py +143 -0
  14. pynode/main.py +118 -0
  15. pynode/node_registry.py +156 -0
  16. pynode/nodes/AutoSyncNode/auto_sync_node.py +151 -0
  17. pynode/nodes/BBoxMetricsNode/__init__.py +3 -0
  18. pynode/nodes/BBoxMetricsNode/bbox_metrics_node.py +250 -0
  19. pynode/nodes/BBoxMetricsNode/requirements.txt +1 -0
  20. pynode/nodes/BatchNode/__init__.py +3 -0
  21. pynode/nodes/BatchNode/batch_node.py +86 -0
  22. pynode/nodes/BatchNode/requirements.txt +1 -0
  23. pynode/nodes/CameraNode/__init__.py +4 -0
  24. pynode/nodes/CameraNode/camera_node.py +204 -0
  25. pynode/nodes/CameraNode/requirements.txt +2 -0
  26. pynode/nodes/ChangeNode/__init__.py +1 -0
  27. pynode/nodes/ChangeNode/change_node.py +255 -0
  28. pynode/nodes/ChangeNode/requirements.txt +1 -0
  29. pynode/nodes/ConfidenceFilterNode/__init__.py +4 -0
  30. pynode/nodes/ConfidenceFilterNode/confidence_filter_node.py +208 -0
  31. pynode/nodes/ConfidenceFilterNode/requirements.txt +1 -0
  32. pynode/nodes/CounterNode/__init__.py +3 -0
  33. pynode/nodes/CounterNode/counter_node.py +191 -0
  34. pynode/nodes/CropNode/__init__.py +4 -0
  35. pynode/nodes/CropNode/crop_node.py +317 -0
  36. pynode/nodes/CropNode/requirements.txt +2 -0
  37. pynode/nodes/DebugNode/__init__.py +4 -0
  38. pynode/nodes/DebugNode/debug_node.py +169 -0
  39. pynode/nodes/DebugNode/requirements.txt +1 -0
  40. pynode/nodes/DelayNode/delay_node.py +218 -0
  41. pynode/nodes/DelayNode/requirements.txt +1 -0
  42. pynode/nodes/DrawPredictionsNode/__init__.py +3 -0
  43. pynode/nodes/DrawPredictionsNode/draw_predictions_node.py +365 -0
  44. pynode/nodes/DrawPredictionsNode/requirements.txt +2 -0
  45. pynode/nodes/ErrorNode/error_node.py +94 -0
  46. pynode/nodes/ErrorNode/requirements.txt +1 -0
  47. pynode/nodes/FilterNode/__init__.py +3 -0
  48. pynode/nodes/FilterNode/filter_node.py +102 -0
  49. pynode/nodes/FilterNode/requirements.txt +1 -0
  50. pynode/nodes/FrameSourceNode/__init__.py +4 -0
  51. pynode/nodes/FrameSourceNode/frame_source_node.py +319 -0
  52. pynode/nodes/FrameSourceNode/requirements.txt +1 -0
  53. pynode/nodes/FunctionNode/__init__.py +3 -0
  54. pynode/nodes/FunctionNode/function_node.py +168 -0
  55. pynode/nodes/FunctionNode/requirements.txt +1 -0
  56. pynode/nodes/GateNode/gate_node.py +60 -0
  57. pynode/nodes/GateNode/requirements.txt +1 -0
  58. pynode/nodes/GetiUploadNode/__init__.py +3 -0
  59. pynode/nodes/GetiUploadNode/geti_upload_node.py +278 -0
  60. pynode/nodes/GetiUploadNode/requirements.txt +1 -0
  61. pynode/nodes/ImageFormatNode/__init__.py +4 -0
  62. pynode/nodes/ImageFormatNode/image_format_node.py +310 -0
  63. pynode/nodes/ImageFormatNode/requirements.txt +2 -0
  64. pynode/nodes/ImageUploadNode/image_upload_node.py +96 -0
  65. pynode/nodes/ImageViewerNode/__init__.py +4 -0
  66. pynode/nodes/ImageViewerNode/image_viewer_node.py +162 -0
  67. pynode/nodes/ImageViewerNode/requirements.txt +2 -0
  68. pynode/nodes/ImageWriterNode/__init__.py +3 -0
  69. pynode/nodes/ImageWriterNode/imagewriter_node.py +352 -0
  70. pynode/nodes/ImageWriterNode/requirements.txt +1 -0
  71. pynode/nodes/InferenceNode/InferenceEngine/__init__.py +15 -0
  72. pynode/nodes/InferenceNode/InferenceEngine/device_detection.py +149 -0
  73. pynode/nodes/InferenceNode/InferenceEngine/engines/base_engine.py +155 -0
  74. pynode/nodes/InferenceNode/InferenceEngine/engines/example_engine_template.py +272 -0
  75. pynode/nodes/InferenceNode/InferenceEngine/engines/geti_engine.py +309 -0
  76. pynode/nodes/InferenceNode/InferenceEngine/engines/onnx_engine.py +346 -0
  77. pynode/nodes/InferenceNode/InferenceEngine/engines/pass_engine.py +86 -0
  78. pynode/nodes/InferenceNode/InferenceEngine/engines/ultralytics_engine.py +863 -0
  79. pynode/nodes/InferenceNode/InferenceEngine/inference_engine_factory.py +533 -0
  80. pynode/nodes/InferenceNode/InferenceEngine/readme.md +107 -0
  81. pynode/nodes/InferenceNode/InferenceEngine/result_converters.py +121 -0
  82. pynode/nodes/InferenceNode/inference_node.py +451 -0
  83. pynode/nodes/InferenceNode/requirements.txt +13 -0
  84. pynode/nodes/InjectNode/inject_node.py +245 -0
  85. pynode/nodes/InjectNode/requirements.txt +1 -0
  86. pynode/nodes/JoinNode/__init__.py +3 -0
  87. pynode/nodes/JoinNode/join_node.py +156 -0
  88. pynode/nodes/JoinNode/requirements.txt +1 -0
  89. pynode/nodes/LabelFilterNode/__init__.py +3 -0
  90. pynode/nodes/LabelFilterNode/label_filter_node.py +188 -0
  91. pynode/nodes/LabelFilterNode/requirements.txt +1 -0
  92. pynode/nodes/MQTTNode/__init__.py +8 -0
  93. pynode/nodes/MQTTNode/mqtt_in_node.py +148 -0
  94. pynode/nodes/MQTTNode/mqtt_out_node.py +157 -0
  95. pynode/nodes/MQTTNode/mqtt_service.py +367 -0
  96. pynode/nodes/MQTTNode/requirements.txt +1 -0
  97. pynode/nodes/MessageWriterNode/__init__.py +7 -0
  98. pynode/nodes/MessageWriterNode/messagereader_node.py +568 -0
  99. pynode/nodes/MessageWriterNode/messagewriter_node.py +505 -0
  100. pynode/nodes/OmronCameraNode/__init__.py +3 -0
  101. pynode/nodes/OmronCameraNode/omron_camera_node.py +652 -0
  102. pynode/nodes/OmronCameraNode/requirements.txt +4 -0
  103. pynode/nodes/OpenCV/__init__.py +1 -0
  104. pynode/nodes/OpenCV/arithmetic_node.py +376 -0
  105. pynode/nodes/OpenCV/bitwise_node.py +127 -0
  106. pynode/nodes/OpenCV/blend_node.py +127 -0
  107. pynode/nodes/OpenCV/blob_detector_node.py +233 -0
  108. pynode/nodes/OpenCV/blur_node.py +141 -0
  109. pynode/nodes/OpenCV/color_space_node.py +139 -0
  110. pynode/nodes/OpenCV/colormap_node.py +262 -0
  111. pynode/nodes/OpenCV/contours_node.py +258 -0
  112. pynode/nodes/OpenCV/denormalize_coords_node.py +365 -0
  113. pynode/nodes/OpenCV/draw_node.py +312 -0
  114. pynode/nodes/OpenCV/edge_detector_node.py +177 -0
  115. pynode/nodes/OpenCV/equirectangular_node.py +447 -0
  116. pynode/nodes/OpenCV/fft_node.py +102 -0
  117. pynode/nodes/OpenCV/focus_estimation_node.py +189 -0
  118. pynode/nodes/OpenCV/frequency_filter_node.py +241 -0
  119. pynode/nodes/OpenCV/histogram_node.py +160 -0
  120. pynode/nodes/OpenCV/hough_circles_node.py +224 -0
  121. pynode/nodes/OpenCV/hough_lines_node.py +243 -0
  122. pynode/nodes/OpenCV/in_range_node.py +186 -0
  123. pynode/nodes/OpenCV/morphology_node.py +136 -0
  124. pynode/nodes/OpenCV/normalize_coords_node.py +344 -0
  125. pynode/nodes/OpenCV/paste_node.py +244 -0
  126. pynode/nodes/OpenCV/perspective_node.py +174 -0
  127. pynode/nodes/OpenCV/realsense_depth_node.py +253 -0
  128. pynode/nodes/OpenCV/requirements.txt +2 -0
  129. pynode/nodes/OpenCV/resize_node.py +175 -0
  130. pynode/nodes/OpenCV/rotate_node.py +159 -0
  131. pynode/nodes/OpenCV/template_match_node.py +227 -0
  132. pynode/nodes/OpenCV/threshold_node.py +167 -0
  133. pynode/nodes/PointInShapeNode/__init__.py +3 -0
  134. pynode/nodes/PointInShapeNode/point_in_shape_node.py +313 -0
  135. pynode/nodes/PointInShapeNode/requirements.txt +2 -0
  136. pynode/nodes/PolygonMetricsNode/__init__.py +3 -0
  137. pynode/nodes/PolygonMetricsNode/polygon_metrics_node.py +331 -0
  138. pynode/nodes/PolygonMetricsNode/requirements.txt +2 -0
  139. pynode/nodes/QueueLengthProbeNode/__init__.py +3 -0
  140. pynode/nodes/QueueLengthProbeNode/queue_length_probe_node.py +116 -0
  141. pynode/nodes/Qwen3VLMNode/__init__.py +3 -0
  142. pynode/nodes/Qwen3VLMNode/qwen3_vlm_node.py +519 -0
  143. pynode/nodes/Qwen3VLMNode/requirements.txt +5 -0
  144. pynode/nodes/RESTEndpointNode/__init__.py +3 -0
  145. pynode/nodes/RESTEndpointNode/rest_endpoint_node.py +491 -0
  146. pynode/nodes/RangeNode/__init__.py +3 -0
  147. pynode/nodes/RangeNode/range_node.py +115 -0
  148. pynode/nodes/RangeNode/requirements.txt +1 -0
  149. pynode/nodes/RateProbeNode/__init__.py +3 -0
  150. pynode/nodes/RateProbeNode/rate_probe_node.py +183 -0
  151. pynode/nodes/RateProbeNode/requirements.txt +1 -0
  152. pynode/nodes/RoboflowUploadNode/__init__.py +3 -0
  153. pynode/nodes/RoboflowUploadNode/requirements.txt +1 -0
  154. pynode/nodes/RoboflowUploadNode/roboflow_upload_node.py +229 -0
  155. pynode/nodes/SliceNode/README.md +149 -0
  156. pynode/nodes/SliceNode/__init__.py +5 -0
  157. pynode/nodes/SliceNode/merge_predictions_node.py +444 -0
  158. pynode/nodes/SliceNode/requirements.txt +3 -0
  159. pynode/nodes/SliceNode/slice_collector_node.py +370 -0
  160. pynode/nodes/SliceNode/slice_image_node.py +380 -0
  161. pynode/nodes/SplitNode/__init__.py +3 -0
  162. pynode/nodes/SplitNode/requirements.txt +1 -0
  163. pynode/nodes/SplitNode/split_node.py +155 -0
  164. pynode/nodes/SwitchNode/requirements.txt +1 -0
  165. pynode/nodes/SwitchNode/switch_node.py +259 -0
  166. pynode/nodes/SyncNode/__init__.py +3 -0
  167. pynode/nodes/SyncNode/sync_node.py +287 -0
  168. pynode/nodes/TemplateNode/requirements.txt +1 -0
  169. pynode/nodes/TemplateNode/template_node.py +86 -0
  170. pynode/nodes/TrackerNode/__init__.py +2 -0
  171. pynode/nodes/TrackerNode/supervision_tracker_node.py +248 -0
  172. pynode/nodes/TrackerNode/tracker_node.py +745 -0
  173. pynode/nodes/UltralyticsNode/__init__.py +4 -0
  174. pynode/nodes/UltralyticsNode/requirements.txt +2 -0
  175. pynode/nodes/UltralyticsNode/ultralytics_node.py +364 -0
  176. pynode/nodes/UnknownNode/__init__.py +93 -0
  177. pynode/nodes/VLLMNode/__init__.py +3 -0
  178. pynode/nodes/VLLMNode/vllm_node.py +345 -0
  179. pynode/nodes/VideoReaderNode/__init__.py +3 -0
  180. pynode/nodes/VideoReaderNode/video_reader_node.py +425 -0
  181. pynode/nodes/VideoWriterNode/__init__.py +3 -0
  182. pynode/nodes/VideoWriterNode/video_writer_node.py +441 -0
  183. pynode/nodes/WebhookNode/__init__.py +3 -0
  184. pynode/nodes/WebhookNode/webhook_node.py +591 -0
  185. pynode/nodes/__init__.py +109 -0
  186. pynode/nodes/base_node.py +562 -0
  187. pynode/nodes/image_utils.py +291 -0
  188. pynode/nodes/info.py +109 -0
  189. pynode/nodes/mDNSNode/__init__.py +13 -0
  190. pynode/nodes/mDNSNode/mdns_broadcast_node.py +214 -0
  191. pynode/nodes/mDNSNode/mdns_discovery_node.py +362 -0
  192. pynode/nodes/mDNSNode/mdns_manager.py +399 -0
  193. pynode/nodes/mDNSNode/requirements.txt +1 -0
  194. pynode/nodes/messages.py +81 -0
  195. pynode/server.py +187 -0
  196. pynode/static/images/favicon4.png +0 -0
  197. pynode/static/index.html +200 -0
  198. pynode/static/js/README.md +139 -0
  199. pynode/static/js/auth.js +89 -0
  200. pynode/static/js/clipboard.js +152 -0
  201. pynode/static/js/config.js +14 -0
  202. pynode/static/js/connections.js +844 -0
  203. pynode/static/js/debug.js +524 -0
  204. pynode/static/js/events.js +775 -0
  205. pynode/static/js/history.js +199 -0
  206. pynode/static/js/main.js +55 -0
  207. pynode/static/js/minimap.js +242 -0
  208. pynode/static/js/nodes.js +995 -0
  209. pynode/static/js/palette.js +255 -0
  210. pynode/static/js/properties.js +1270 -0
  211. pynode/static/js/selection.js +241 -0
  212. pynode/static/js/state.js +211 -0
  213. pynode/static/js/ui-utils.js +28 -0
  214. pynode/static/js/viewport.js +204 -0
  215. pynode/static/js/workflow.js +469 -0
  216. pynode/static/js/workflows.js +344 -0
  217. pynode/static/style.css +2751 -0
  218. pynode/workflow_engine.py +483 -0
  219. pynode/workflow_manager.py +440 -0
  220. pynode_flow-0.2.0.dist-info/METADATA +520 -0
  221. pynode_flow-0.2.0.dist-info/RECORD +227 -0
  222. pynode_flow-0.2.0.dist-info/WHEEL +5 -0
  223. pynode_flow-0.2.0.dist-info/entry_points.txt +3 -0
  224. pynode_flow-0.2.0.dist-info/licenses/LICENSE +21 -0
  225. pynode_flow-0.2.0.dist-info/scm_file_list.json +270 -0
  226. pynode_flow-0.2.0.dist-info/scm_version.json +8 -0
  227. pynode_flow-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,995 @@
1
+ // Node rendering and management
2
+ import { API_BASE } from './config.js';
3
+ import { state, generateNodeId, markNodeModified, markNodeAdded, markNodeDeleted, markConnectionDeleted, setModified, getNodeType } from './state.js';
4
+ import { updateConnections, nodeHasConnections, getConnectionAtPoint, highlightConnectionForInsert, clearConnectionHighlight, getHoveredConnection, insertNodeIntoConnection } from './connections.js';
5
+ import { selectNode, selectPathBetweenNodes } from './selection.js';
6
+ import { clientToCanvas, getZoom } from './viewport.js';
7
+ import { refreshMinimap } from './minimap.js';
8
+
9
+ const GRID_SIZE = 20;
10
+
11
+ function clampToCanvasBounds(x, y) {
12
+ // Keep nodes within the drawable canvas area (best-effort).
13
+ const min = 0;
14
+ const maxX = 5000;
15
+ const maxY = 5000;
16
+ return {
17
+ x: Math.min(Math.max(x, min), maxX),
18
+ y: Math.min(Math.max(y, min), maxY)
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Get the center of the snap anchor port in canvas coordinates.
24
+ * For nodes with inputs: uses input port 0
25
+ * For output-only nodes: uses output port 0
26
+ */
27
+ function getSnapAnchorPortCenter(nodeEl) {
28
+ const nodesContainer = document.getElementById('nodes-container');
29
+ if (!nodesContainer) return null;
30
+
31
+ // Try input port 0 first
32
+ let portEl = nodeEl.querySelector('.port.input[data-index="0"]') || nodeEl.querySelector('.port.input');
33
+
34
+ // If no input port, use output port 0 (for output-only nodes like Inject, Camera, etc.)
35
+ if (!portEl) {
36
+ portEl = nodeEl.querySelector('.port.output[data-index="0"]') || nodeEl.querySelector('.port.output');
37
+ }
38
+
39
+ if (!portEl) return null;
40
+
41
+ // Port center in canvas coordinates (zoom-aware)
42
+ const portRect = portEl.getBoundingClientRect();
43
+ return clientToCanvas(
44
+ portRect.left + portRect.width / 2,
45
+ portRect.top + portRect.height / 2
46
+ );
47
+ }
48
+
49
+ export function snapNodeToGrid(nodeId, gridSize = GRID_SIZE) {
50
+ const nodeData = state.nodes.get(nodeId);
51
+ const nodeEl = document.getElementById(`node-${nodeId}`);
52
+ if (!nodeData || !nodeEl) return false;
53
+
54
+ const portCenter = getSnapAnchorPortCenter(nodeEl);
55
+ if (!portCenter) return false;
56
+
57
+ const snappedX = Math.round(portCenter.x / gridSize) * gridSize;
58
+ const snappedY = Math.round(portCenter.y / gridSize) * gridSize;
59
+ const deltaX = snappedX - portCenter.x;
60
+ const deltaY = snappedY - portCenter.y;
61
+
62
+ if (deltaX === 0 && deltaY === 0) return true;
63
+
64
+ const next = clampToCanvasBounds(nodeData.x + deltaX, nodeData.y + deltaY);
65
+ nodeData.x = next.x;
66
+ nodeData.y = next.y;
67
+ nodeEl.style.left = `${nodeData.x}px`;
68
+ nodeEl.style.top = `${nodeData.y}px`;
69
+ updateConnections();
70
+ return true;
71
+ }
72
+
73
+ function snapSelectedNodesToGrid(anchorNodeId, gridSize = GRID_SIZE) {
74
+ const anchorEl = document.getElementById(`node-${anchorNodeId}`);
75
+ if (!anchorEl) return;
76
+
77
+ const anchorPortCenter = getSnapAnchorPortCenter(anchorEl);
78
+ if (!anchorPortCenter) return;
79
+
80
+ const snappedX = Math.round(anchorPortCenter.x / gridSize) * gridSize;
81
+ const snappedY = Math.round(anchorPortCenter.y / gridSize) * gridSize;
82
+ const deltaX = snappedX - anchorPortCenter.x;
83
+ const deltaY = snappedY - anchorPortCenter.y;
84
+
85
+ if (deltaX === 0 && deltaY === 0) return;
86
+
87
+ state.selectedNodes.forEach(selectedId => {
88
+ const selectedNodeData = state.nodes.get(selectedId);
89
+ const selectedNodeEl = document.getElementById(`node-${selectedId}`);
90
+ if (!selectedNodeData || !selectedNodeEl) return;
91
+
92
+ const next = clampToCanvasBounds(selectedNodeData.x + deltaX, selectedNodeData.y + deltaY);
93
+ selectedNodeData.x = next.x;
94
+ selectedNodeData.y = next.y;
95
+ selectedNodeEl.style.left = `${selectedNodeData.x}px`;
96
+ selectedNodeEl.style.top = `${selectedNodeData.y}px`;
97
+ });
98
+
99
+ updateConnections();
100
+ }
101
+
102
+ /**
103
+ * Generate a unique name for a node by checking existing node names
104
+ */
105
+ export function generateUniqueName(baseName, nodeType = null) {
106
+ // Extract base name without number suffix (e.g., "debug 2" -> "debug")
107
+ let cleanBaseName = baseName;
108
+ const match = baseName.match(/^(.+?)\s+(\d+)$/);
109
+ if (match) {
110
+ cleanBaseName = match[1];
111
+ }
112
+
113
+ let uniqueName = cleanBaseName;
114
+ let counter = 1;
115
+
116
+ // Check if name already exists
117
+ const existingNames = new Set();
118
+ state.nodes.forEach(node => {
119
+ // If nodeType is provided, only check nodes of the same type
120
+ if (!nodeType || node.type === nodeType) {
121
+ existingNames.add(node.name.toLowerCase());
122
+ }
123
+ });
124
+
125
+ // If base name exists, try with numbers
126
+ while (existingNames.has(uniqueName.toLowerCase())) {
127
+ counter++;
128
+ uniqueName = `${cleanBaseName} ${counter}`;
129
+ }
130
+
131
+ return uniqueName;
132
+ }
133
+
134
+ export function createNode(type, x, y) {
135
+ const nodeType = getNodeType(type);
136
+ const displayName = nodeType ? nodeType.name : type;
137
+
138
+ // Generate unique name by checking existing nodes
139
+ const uniqueName = generateUniqueName(displayName, type);
140
+
141
+
142
+ // Save state before creating node
143
+ import('./history.js').then(({ saveState }) => {
144
+ saveState('create node');
145
+ });
146
+
147
+ // Initialize config with default values from properties
148
+ // Use structuredClone to avoid sharing array/object references between node instances
149
+ const config = {};
150
+ if (nodeType && nodeType.properties) {
151
+ for (const prop of nodeType.properties) {
152
+ if (prop.default !== undefined) {
153
+ config[prop.name] = structuredClone(prop.default);
154
+ }
155
+ }
156
+ }
157
+
158
+ const nodeData = {
159
+ id: generateNodeId(),
160
+ type: type,
161
+ name: uniqueName,
162
+ config: config,
163
+ x: x,
164
+ y: y
165
+ };
166
+
167
+ if (nodeType) {
168
+ nodeData.color = nodeType.color;
169
+ nodeData.borderColor = nodeType.borderColor;
170
+ nodeData.textColor = nodeType.textColor;
171
+ nodeData.icon = nodeType.icon;
172
+ nodeData.inputCount = nodeType.inputCount;
173
+ nodeData.outputCount = nodeType.outputCount;
174
+ }
175
+
176
+ state.nodes.set(nodeData.id, nodeData);
177
+ renderNode(nodeData);
178
+ markNodeAdded(nodeData.id);
179
+ markNodeModified(nodeData.id);
180
+ setModified(true);
181
+
182
+ return nodeData.id;
183
+ }
184
+
185
+ // Track z-index for node layering
186
+ let nodeZIndex = 1;
187
+
188
+ export function renderNode(nodeData) {
189
+ const nodeEl = document.createElement('div');
190
+ nodeEl.className = 'node';
191
+ nodeEl.id = `node-${nodeData.id}`;
192
+ nodeEl.style.left = `${nodeData.x}px`;
193
+ nodeEl.style.top = `${nodeData.y}px`;
194
+ nodeEl.style.zIndex = nodeZIndex++;
195
+
196
+ // Add special class for ImageViewerNode
197
+ if (nodeData.type === 'ImageViewerNode') {
198
+ nodeEl.classList.add('image-viewer-node');
199
+ }
200
+
201
+ // Add special class for unknown/placeholder nodes
202
+ if (nodeData.isUnknownNode) {
203
+ nodeEl.classList.add('unknown-node');
204
+ }
205
+
206
+ // Add disabled class if node is disabled
207
+ if (nodeData.enabled === false) {
208
+ nodeEl.classList.add('disabled');
209
+ }
210
+
211
+ // Apply custom colors
212
+ if (nodeData.color) nodeEl.style.backgroundColor = nodeData.color;
213
+ if (nodeData.borderColor) nodeEl.style.borderColor = nodeData.borderColor;
214
+ if (nodeData.textColor) nodeEl.style.color = nodeData.textColor;
215
+
216
+ const inputCount = nodeData.inputCount !== undefined ? nodeData.inputCount : 1;
217
+ const outputCount = nodeData.outputCount !== undefined ? nodeData.outputCount : 1;
218
+
219
+ // Calculate and apply dynamic height based on port count (whichever is larger)
220
+ const maxPorts = Math.max(inputCount, outputCount);
221
+ if (maxPorts > 1) {
222
+ // Base height (30px) + additional height for extra ports
223
+ // Each port is 10px + 4px gap, need at least 14px per port after the first
224
+ const extraHeight = (maxPorts - 1) * 14;
225
+ const totalHeight = 30 + extraHeight;
226
+ nodeEl.style.minHeight = `${totalHeight}px`;
227
+ }
228
+ const icon = nodeData.icon || '◆';
229
+
230
+ // Build node content HTML
231
+ let nodeContent = buildNodeContent(nodeData, icon, inputCount, outputCount);
232
+
233
+ // Generate ports HTML with support for multiple inputs and outputs
234
+ let portsHtml = '';
235
+ if (inputCount > 0 || outputCount > 0) {
236
+ let inputPortsHtml = '';
237
+ if (inputCount > 0) {
238
+ if (inputCount === 1) {
239
+ inputPortsHtml = `<div class="port input" data-node="${nodeData.id}" data-type="input" data-index="0"></div>`;
240
+ } else {
241
+ // Multiple inputs - stack them vertically
242
+ inputPortsHtml = '<div class="input-ports-container">';
243
+ for (let i = 0; i < inputCount; i++) {
244
+ inputPortsHtml += `<div class="port input" data-node="${nodeData.id}" data-type="input" data-index="${i}"></div>`;
245
+ }
246
+ inputPortsHtml += '</div>';
247
+ }
248
+ }
249
+
250
+ let outputPortsHtml = '';
251
+ if (outputCount > 0) {
252
+ if (outputCount === 1) {
253
+ outputPortsHtml = `<div class="port output" data-node="${nodeData.id}" data-type="output" data-index="0"></div>`;
254
+ } else {
255
+ // Multiple outputs - stack them vertically
256
+ outputPortsHtml = '<div class="output-ports-container">';
257
+ for (let i = 0; i < outputCount; i++) {
258
+ outputPortsHtml += `<div class="port output" data-node="${nodeData.id}" data-type="output" data-index="${i}"></div>`;
259
+ }
260
+ outputPortsHtml += '</div>';
261
+ }
262
+ }
263
+
264
+ portsHtml = `
265
+ <div class="node-ports">
266
+ ${inputPortsHtml}
267
+ ${outputPortsHtml}
268
+ </div>
269
+ `;
270
+ }
271
+
272
+ let imageViewerHtml = '';
273
+ if (nodeData.type === 'ImageViewerNode') {
274
+ const width = nodeData.config?.width || 320;
275
+ const height = nodeData.config?.height || 240;
276
+ imageViewerHtml = `
277
+ <div class="image-viewer-container" id="viewer-container-${nodeData.id}" style="width: ${width}px; height: ${height}px;">
278
+ <img id="viewer-${nodeData.id}" class="image-viewer-frame" alt="No frame" />
279
+ <div class="image-viewer-resize-handle" data-node="${nodeData.id}"></div>
280
+ </div>
281
+ `;
282
+ }
283
+
284
+ nodeEl.innerHTML = `
285
+ <div class="node-modified-indicator"></div>
286
+ ${nodeContent}
287
+ ${portsHtml}
288
+ ${imageViewerHtml}
289
+ `;
290
+
291
+ attachNodeEventHandlers(nodeEl, nodeData);
292
+ document.getElementById('nodes-container').appendChild(nodeEl);
293
+
294
+ // Covers node create, paste, import and tab-switch re-render paths
295
+ refreshMinimap();
296
+ }
297
+
298
+ function buildNodeContent(nodeData, icon, inputCount, outputCount) {
299
+ const nodeType = getNodeType(nodeData.type);
300
+ const uiComponent = nodeType?.uiComponent;
301
+ const uiConfig = nodeType?.uiComponentConfig || {};
302
+
303
+ // Build the base content structure
304
+ let contentParts = {
305
+ left: '',
306
+ center: '',
307
+ right: '',
308
+ below: ''
309
+ };
310
+
311
+ // Icon always in center-left
312
+ contentParts.center = `<div class="node-icon-container"><div class="node-icon">${icon}</div></div>`;
313
+
314
+ // Title always in center
315
+ contentParts.center += `<div class="node-title">${nodeData.name}</div>`;
316
+
317
+ // Add UI component based on type
318
+ if (uiComponent === 'button') {
319
+ // Button on the left (like InjectNode)
320
+ const buttonIcon = uiConfig.icon || '▶';
321
+ const action = uiConfig.action || 'inject';
322
+ const tooltip = uiConfig.tooltip || 'Trigger';
323
+ contentParts.left = `<button class="inject-btn" onclick="window.nodeAction('${nodeData.id}', '${action}')" title="${tooltip}">${buttonIcon}</button>`;
324
+ } else if (uiComponent === 'toggle') {
325
+ // Toggle switch on the right (like GateNode, DebugNode, DrawPredictionsNode)
326
+ const action = uiConfig.action || 'toggle';
327
+ const isChecked = nodeData.enabled !== false && nodeData.drawingEnabled !== false;
328
+ contentParts.right = `
329
+ <label class="gate-switch">
330
+ <input type="checkbox" id="toggle-${nodeData.id}" ${isChecked ? 'checked' : ''}
331
+ onchange="window.nodeAction('${nodeData.id}', '${action}', this.checked)">
332
+ <span class="gate-slider"></span>
333
+ </label>
334
+ `;
335
+ } else if (uiComponent === 'rate-display') {
336
+ // Rate display on the right (like RateProbeNode)
337
+ const format = uiConfig.format || '{value}';
338
+ contentParts.right = `<div class="rate-display" id="rate-${nodeData.id}">0/s</div>`;
339
+ } else if (uiComponent === 'queue-length-display') {
340
+ // Queue length display on the right (like QueueLengthProbeNode)
341
+ const format = uiConfig.format || '{value} queued';
342
+ contentParts.right = `<div class="queue-length-display" id="queue-${nodeData.id}">0 queued</div>`;
343
+ } else if (uiComponent === 'counter-display') {
344
+ // Counter display with reset button (like CounterNode)
345
+ const action = uiConfig.action || 'reset_counter';
346
+ contentParts.left = `<button class="inject-btn" onclick="window.nodeAction('${nodeData.id}', '${action}')" title="${uiConfig.tooltip || 'Reset'}">↻</button>`;
347
+ contentParts.right = `<div class="counter-display" id="counter-${nodeData.id}">0</div>`;
348
+ } else if (uiComponent === 'image-drop') {
349
+ // Drop zone indicator on the left (like ImageUploadNode)
350
+ const tooltip = uiConfig.tooltip || 'Drop an image here';
351
+ contentParts.left = `<div class="image-drop-zone" id="drop-${nodeData.id}" title="${tooltip}">📂</div>`;
352
+ } else if (uiComponent === 'transport-controls') {
353
+ // Generic transport/button strip (like VideoReaderNode).
354
+ // Buttons come from uiComponentConfig.buttons: [{icon, action, title}]
355
+ // and each POSTs its action to /api/nodes/<id>/<action>.
356
+ const buttons = (uiConfig.buttons || []).map(btn =>
357
+ `<button class="transport-btn" onclick="window.nodeAction('${nodeData.id}', '${btn.action}')" title="${btn.title || btn.action}">${btn.icon}</button>`
358
+ ).join('');
359
+ contentParts.right = `
360
+ <div class="transport-controls">
361
+ ${buttons}
362
+ <span class="transport-pos" id="transport-pos-${nodeData.id}">-/-</span>
363
+ </div>
364
+ `;
365
+ // Full-width progress/scrub slider along the bottom of the node.
366
+ // `oninput` previews the position while dragging; `onchange` (on
367
+ // release) issues the seek. The node-drag handler ignores this
368
+ // element so grabbing the thumb doesn't move the node.
369
+ contentParts.below = `
370
+ <input type="range" class="transport-progress"
371
+ id="transport-progress-${nodeData.id}"
372
+ min="0" max="0" value="0" step="1" title="Seek"
373
+ oninput="window.videoSeekPreview('${nodeData.id}', this.value)"
374
+ onchange="window.videoSeek('${nodeData.id}', this.value)" />
375
+ `;
376
+ }
377
+
378
+ // Combine parts based on input/output configuration
379
+ if (inputCount === 0 && outputCount > 0) {
380
+ // Input node - button/icon on left
381
+ return `<div class="node-content">${contentParts.left}${contentParts.center}${contentParts.right}</div>${contentParts.below}`;
382
+ } else if (inputCount > 0 && outputCount === 0) {
383
+ // Output node - title first, then icon, then controls
384
+ return `<div class="node-content">${contentParts.center}${contentParts.right}</div>${contentParts.below}`;
385
+ } else {
386
+ // Processing node - standard layout
387
+ return `<div class="node-content">${contentParts.left}${contentParts.center}${contentParts.right}</div>${contentParts.below}`;
388
+ }
389
+ }
390
+
391
+ function attachNodeEventHandlers(nodeEl, nodeData) {
392
+ let isDragging = false;
393
+ let startX, startY;
394
+ let hasMoved = false;
395
+ let isUnconnectedNode = false;
396
+ let snapAnchorPortOffset = null;
397
+
398
+ nodeEl.addEventListener('mousedown', (e) => {
399
+ if (e.target.classList.contains('port')) return;
400
+ // Don't start a node drag when the user grabs the progress slider -
401
+ // let the range input handle the scrub gesture.
402
+ if (e.target.classList.contains('transport-progress')) return;
403
+
404
+ // Only handle left mouse button (button === 0) for node selection and dragging
405
+ if (e.button !== 0) return;
406
+
407
+ // e.preventDefault() below suppresses the browser's default focus shift,
408
+ // which would otherwise leave focus stuck in the palette search or a
409
+ // properties-panel input - silently swallowing the Delete key
410
+ // (see the isEditingInput guard in events.js). Blur any focused form
411
+ // field outside this node so keyboard shortcuts target the canvas.
412
+ const activeEl = document.activeElement;
413
+ if (activeEl && activeEl !== document.body && !nodeEl.contains(activeEl) &&
414
+ typeof activeEl.blur === 'function') {
415
+ activeEl.blur();
416
+ }
417
+
418
+ // Bring node to front
419
+ nodeEl.style.zIndex = nodeZIndex++;
420
+
421
+ isDragging = true;
422
+ hasMoved = false;
423
+ // Pointer offset from the node origin, in CANVAS coordinates so
424
+ // dragging tracks the cursor 1:1 at every zoom level.
425
+ const pointerCanvas = clientToCanvas(e.clientX, e.clientY);
426
+ startX = pointerCanvas.x - nodeData.x;
427
+ startY = pointerCanvas.y - nodeData.y;
428
+
429
+ // Cache the offset from node top-left to snap anchor port center (in canvas/container coords).
430
+ // For nodes with inputs: uses input port 0
431
+ // For output-only nodes: uses output port 0
432
+ // This lets us snap without forcing repeated DOM reads for the port each frame.
433
+ const portCenter = getSnapAnchorPortCenter(nodeEl);
434
+ if (portCenter) {
435
+ snapAnchorPortOffset = {
436
+ x: portCenter.x - nodeData.x,
437
+ y: portCenter.y - nodeData.y
438
+ };
439
+ } else {
440
+ snapAnchorPortOffset = null;
441
+ }
442
+
443
+ // Check if this node has no connections (for hover-insert highlighting)
444
+ isUnconnectedNode = !nodeHasConnections(nodeData.id);
445
+
446
+ if (e.shiftKey && state.selectedNodes.size === 1) {
447
+ // Shift+click with one node selected: select the path between them
448
+ const firstSelectedId = state.selectedNodes.values().next().value;
449
+ if (firstSelectedId !== nodeData.id) {
450
+ selectPathBetweenNodes(firstSelectedId, nodeData.id);
451
+ }
452
+ } else if (e.ctrlKey || e.metaKey) {
453
+ selectNode(nodeData.id, true);
454
+ } else if (!state.selectedNodes.has(nodeData.id)) {
455
+ selectNode(nodeData.id, false);
456
+ }
457
+
458
+ e.preventDefault();
459
+ });
460
+
461
+ // Double-click to open properties
462
+ nodeEl.addEventListener('dblclick', (e) => {
463
+ if (e.target.classList.contains('port')) return;
464
+ // Don't open the config panel when double-clicking an interactive
465
+ // control on the node (buttons, toggles, inputs, drop zones, resize
466
+ // handle) - only the non-interactive node body should open it.
467
+ if (e.target.closest(
468
+ 'button, input, select, textarea, label, a, ' +
469
+ '.image-drop-zone, .image-viewer-resize-handle'
470
+ )) return;
471
+
472
+ // Select the node if not already selected
473
+ if (!state.selectedNodes.has(nodeData.id)) {
474
+ selectNode(nodeData.id, false);
475
+ }
476
+
477
+ // Show properties panel and render properties
478
+ import('./properties.js').then(({ renderProperties }) => {
479
+ renderProperties(nodeData);
480
+ });
481
+
482
+ e.preventDefault();
483
+ });
484
+
485
+ document.addEventListener('mousemove', (e) => {
486
+ if (!isDragging) return;
487
+
488
+ if (!hasMoved) {
489
+ // Save state when movement starts
490
+ hasMoved = true;
491
+ import('./history.js').then(({ saveState }) => {
492
+ saveState('move node');
493
+ });
494
+ }
495
+
496
+ // Desired anchor node position from pointer (canvas coordinates)
497
+ const pointerCanvas = clientToCanvas(e.clientX, e.clientY);
498
+ let nextAnchorX = pointerCanvas.x - startX;
499
+ let nextAnchorY = pointerCanvas.y - startY;
500
+
501
+ // Snap so the anchor port center lands on the nearest grid intersection.
502
+ if (snapAnchorPortOffset) {
503
+ const desiredPortX = nextAnchorX + snapAnchorPortOffset.x;
504
+ const desiredPortY = nextAnchorY + snapAnchorPortOffset.y;
505
+ const snappedPortX = Math.round(desiredPortX / GRID_SIZE) * GRID_SIZE;
506
+ const snappedPortY = Math.round(desiredPortY / GRID_SIZE) * GRID_SIZE;
507
+ nextAnchorX = snappedPortX - snapAnchorPortOffset.x;
508
+ nextAnchorY = snappedPortY - snapAnchorPortOffset.y;
509
+ } else {
510
+ // Fallback: no input port 0, snap the node top-left.
511
+ nextAnchorX = Math.round(nextAnchorX / GRID_SIZE) * GRID_SIZE;
512
+ nextAnchorY = Math.round(nextAnchorY / GRID_SIZE) * GRID_SIZE;
513
+ }
514
+
515
+ const clampedAnchor = clampToCanvasBounds(nextAnchorX, nextAnchorY);
516
+ nextAnchorX = clampedAnchor.x;
517
+ nextAnchorY = clampedAnchor.y;
518
+
519
+ const deltaX = nextAnchorX - nodeData.x;
520
+ const deltaY = nextAnchorY - nodeData.y;
521
+
522
+ state.selectedNodes.forEach(selectedId => {
523
+ const selectedNodeData = state.nodes.get(selectedId);
524
+ const selectedNodeEl = document.getElementById(`node-${selectedId}`);
525
+ if (selectedNodeData && selectedNodeEl) {
526
+ const next = clampToCanvasBounds(selectedNodeData.x + deltaX, selectedNodeData.y + deltaY);
527
+ selectedNodeData.x = next.x;
528
+ selectedNodeData.y = next.y;
529
+ selectedNodeEl.style.left = `${selectedNodeData.x}px`;
530
+ selectedNodeEl.style.top = `${selectedNodeData.y}px`;
531
+ // Mark as modified when moved
532
+ markNodeModified(selectedId);
533
+ setModified(true);
534
+ }
535
+ });
536
+
537
+ nodeData.x = nextAnchorX;
538
+ nodeData.y = nextAnchorY;
539
+
540
+ updateConnections();
541
+
542
+ // Check for connection hover if this is an unconnected node
543
+ // Must happen after updateConnections() to ensure highlight persists
544
+ if (isUnconnectedNode && state.selectedNodes.size === 1) {
545
+ // Get node center position in canvas coordinates (zoom-aware)
546
+ const nodeRect = nodeEl.getBoundingClientRect();
547
+ const nodeCenter = clientToCanvas(
548
+ nodeRect.left + nodeRect.width / 2,
549
+ nodeRect.top + nodeRect.height / 2
550
+ );
551
+
552
+ const hoveredConnection = getConnectionAtPoint(nodeCenter.x, nodeCenter.y, 15 / getZoom());
553
+ if (hoveredConnection) {
554
+ highlightConnectionForInsert(hoveredConnection);
555
+ } else {
556
+ clearConnectionHighlight();
557
+ }
558
+ }
559
+ });
560
+
561
+ document.addEventListener('mouseup', () => {
562
+ if (isDragging) {
563
+ // Check if we should insert this node into a connection
564
+ const connectionToInsertInto = getHoveredConnection();
565
+
566
+ // Clear any connection highlight when drag ends
567
+ clearConnectionHighlight();
568
+
569
+ // If this was an unconnected node dropped on a connection, insert it
570
+ if (isUnconnectedNode && connectionToInsertInto) {
571
+ insertNodeIntoConnection(nodeData.id, connectionToInsertInto);
572
+ }
573
+
574
+ // Snap the moved selection so anchor input port 0 aligns to the grid.
575
+ if (hasMoved) {
576
+ snapSelectedNodesToGrid(nodeData.id);
577
+ }
578
+ }
579
+ isDragging = false;
580
+ hasMoved = false;
581
+ isUnconnectedNode = false;
582
+ snapAnchorPortOffset = null;
583
+ });
584
+
585
+ // Port connection handling - support multiple output ports
586
+ const outputPorts = nodeEl.querySelectorAll('.port.output');
587
+ outputPorts.forEach(outputPort => {
588
+ outputPort.addEventListener('mousedown', (e) => {
589
+ e.stopPropagation();
590
+ const outputIndex = parseInt(outputPort.getAttribute('data-index') || '0');
591
+ // Import dynamically to avoid circular dependency
592
+ import('./connections.js').then(({ startConnection }) => {
593
+ startConnection(nodeData.id, e, outputIndex);
594
+ });
595
+ });
596
+ });
597
+
598
+ // Add mouseup handlers on input ports for connection targeting (forward connection)
599
+ const inputPorts = nodeEl.querySelectorAll('.port.input');
600
+ inputPorts.forEach(inputPort => {
601
+ // Mouseup for completing forward connections (output -> input)
602
+ inputPort.addEventListener('mouseup', (e) => {
603
+ if (state.drawingConnection && !state.drawingConnection.backward &&
604
+ state.drawingConnection.sourceId !== nodeData.id) {
605
+ e.stopPropagation();
606
+ const inputIndex = parseInt(inputPort.getAttribute('data-index') || '0');
607
+ import('./connections.js').then(({ endConnection }) => {
608
+ endConnection(nodeData.id, inputIndex);
609
+ });
610
+ }
611
+ });
612
+
613
+ // Mousedown for starting backward connections (input -> output)
614
+ inputPort.addEventListener('mousedown', (e) => {
615
+ e.stopPropagation();
616
+ const inputIndex = parseInt(inputPort.getAttribute('data-index') || '0');
617
+ import('./connections.js').then(({ startBackwardConnection }) => {
618
+ startBackwardConnection(nodeData.id, e, inputIndex);
619
+ });
620
+ });
621
+ });
622
+
623
+ // Add mouseup handlers on output ports for completing backward connections
624
+ outputPorts.forEach(outputPort => {
625
+ outputPort.addEventListener('mouseup', (e) => {
626
+ if (state.drawingConnection && state.drawingConnection.backward &&
627
+ state.drawingConnection.targetId !== nodeData.id) {
628
+ e.stopPropagation();
629
+ const outputIndex = parseInt(outputPort.getAttribute('data-index') || '0');
630
+ import('./connections.js').then(({ endBackwardConnection }) => {
631
+ endBackwardConnection(nodeData.id, outputIndex);
632
+ });
633
+ }
634
+ });
635
+ });
636
+
637
+ // Also keep the node-level handler for dropping on the node body (defaults to input 0)
638
+ nodeEl.addEventListener('mouseup', (e) => {
639
+ if (state.drawingConnection && !state.drawingConnection.backward &&
640
+ state.drawingConnection.sourceId !== nodeData.id) {
641
+ // Only handle if not already handled by an input port
642
+ if (!e.target.classList.contains('port')) {
643
+ e.stopPropagation();
644
+ import('./connections.js').then(({ endConnection }) => {
645
+ endConnection(nodeData.id, 0);
646
+ });
647
+ }
648
+ }
649
+ });
650
+
651
+ // Image upload drag-and-drop handler
652
+ const nodeType = getNodeType(nodeData.type);
653
+ if (nodeType?.uiComponent === 'image-drop') {
654
+ const dropZone = nodeEl.querySelector('.image-drop-zone');
655
+
656
+ const preventAndHighlight = (e) => {
657
+ e.preventDefault();
658
+ e.stopPropagation();
659
+ nodeEl.classList.add('image-drop-active');
660
+ };
661
+
662
+ nodeEl.addEventListener('dragover', preventAndHighlight);
663
+ nodeEl.addEventListener('dragenter', preventAndHighlight);
664
+
665
+ nodeEl.addEventListener('dragleave', (e) => {
666
+ e.preventDefault();
667
+ e.stopPropagation();
668
+ // Only remove highlight when actually leaving the node
669
+ if (!nodeEl.contains(e.relatedTarget)) {
670
+ nodeEl.classList.remove('image-drop-active');
671
+ }
672
+ });
673
+
674
+ nodeEl.addEventListener('drop', (e) => {
675
+ e.preventDefault();
676
+ e.stopPropagation();
677
+ nodeEl.classList.remove('image-drop-active');
678
+
679
+ const files = e.dataTransfer?.files;
680
+ if (!files || files.length === 0) return;
681
+
682
+ const file = files[0];
683
+ if (!file.type.startsWith('image/')) {
684
+ console.warn('Dropped file is not an image:', file.type);
685
+ return;
686
+ }
687
+
688
+ window.uploadImageToNode(nodeData.id, file);
689
+ });
690
+
691
+ // Also allow clicking the drop zone to open a file picker
692
+ if (dropZone) {
693
+ dropZone.addEventListener('click', (e) => {
694
+ e.stopPropagation();
695
+ const input = document.createElement('input');
696
+ input.type = 'file';
697
+ input.accept = 'image/*';
698
+ input.onchange = (ev) => {
699
+ const file = ev.target.files[0];
700
+ if (file) window.uploadImageToNode(nodeData.id, file);
701
+ };
702
+ input.click();
703
+ });
704
+ }
705
+ }
706
+
707
+ // Image viewer resize handler
708
+ if (nodeData.type === 'ImageViewerNode') {
709
+ const resizeHandle = nodeEl.querySelector('.image-viewer-resize-handle');
710
+ const viewerContainer = nodeEl.querySelector('.image-viewer-container');
711
+
712
+ if (resizeHandle && viewerContainer) {
713
+ let isResizing = false;
714
+ let startWidth, startHeight, startX, startY;
715
+
716
+ resizeHandle.addEventListener('mousedown', (e) => {
717
+ e.stopPropagation();
718
+ e.preventDefault();
719
+
720
+ isResizing = true;
721
+ startWidth = viewerContainer.offsetWidth;
722
+ startHeight = viewerContainer.offsetHeight;
723
+ startX = e.clientX;
724
+ startY = e.clientY;
725
+
726
+ document.body.style.cursor = 'nwse-resize';
727
+ });
728
+
729
+ const handleResize = (e) => {
730
+ if (!isResizing) return;
731
+
732
+ // Viewer size is in canvas px; convert the client-px mouse
733
+ // delta so resizing tracks the cursor at every zoom level.
734
+ const zoom = getZoom();
735
+ const deltaX = (e.clientX - startX) / zoom;
736
+ const deltaY = (e.clientY - startY) / zoom;
737
+
738
+ const newWidth = Math.max(160, startWidth + deltaX);
739
+ const newHeight = Math.max(120, startHeight + deltaY);
740
+
741
+ viewerContainer.style.width = `${newWidth}px`;
742
+ viewerContainer.style.height = `${newHeight}px`;
743
+
744
+ // Update config for persistence
745
+ nodeData.config.width = newWidth;
746
+ nodeData.config.height = newHeight;
747
+
748
+ // Mark as modified
749
+ markNodeModified(nodeData.id);
750
+ setModified(true);
751
+ };
752
+
753
+ const stopResize = () => {
754
+ if (isResizing) {
755
+ isResizing = false;
756
+ document.body.style.cursor = '';
757
+ }
758
+ };
759
+
760
+ document.addEventListener('mousemove', handleResize);
761
+ document.addEventListener('mouseup', stopResize);
762
+ }
763
+ }
764
+ }
765
+
766
+ export function deleteNode(nodeId) {
767
+ // Track deleted connections for incremental deploy. This must run
768
+ // synchronously BEFORE the connections are filtered out below - the old
769
+ // dynamic import ran as a microtask after the filter, so it always saw an
770
+ // empty result and deleted connections were never tracked.
771
+ state.connections.forEach(c => {
772
+ if (c.source === nodeId || c.target === nodeId) {
773
+ markConnectionDeleted(c);
774
+ }
775
+ });
776
+
777
+ state.nodes.delete(nodeId);
778
+ state.connections = state.connections.filter(
779
+ c => c.source !== nodeId && c.target !== nodeId
780
+ );
781
+
782
+ document.getElementById(`node-${nodeId}`)?.remove();
783
+ updateConnections();
784
+
785
+ if (state.selectedNode === nodeId) {
786
+ import('./selection.js').then(({ deselectNode }) => deselectNode());
787
+ }
788
+
789
+ // Track deleted node for incremental deploy
790
+ markNodeDeleted(nodeId);
791
+ setModified(true);
792
+ }
793
+
794
+ // Delete a node but reconnect the nodes on either side
795
+ export function deleteNodeAndReconnect(nodeId) {
796
+ const nodeData = state.nodes.get(nodeId);
797
+ if (!nodeData) return;
798
+
799
+ // Find all incoming connections (where this node is the target)
800
+ const incomingConnections = state.connections.filter(c => c.target === nodeId);
801
+
802
+ // Find all outgoing connections (where this node is the source)
803
+ const outgoingConnections = state.connections.filter(c => c.source === nodeId);
804
+
805
+ // Import createConnection to make the new connections
806
+ import('./connections.js').then(({ createConnection }) => {
807
+ // Connect each incoming source to each outgoing target
808
+ incomingConnections.forEach(incoming => {
809
+ outgoingConnections.forEach(outgoing => {
810
+ // Check if connection already exists
811
+ const exists = state.connections.some(c =>
812
+ c.source === incoming.source &&
813
+ c.target === outgoing.target &&
814
+ c.sourceOutput === incoming.sourceOutput
815
+ );
816
+
817
+ if (!exists) {
818
+ createConnection(
819
+ incoming.source,
820
+ outgoing.target,
821
+ incoming.sourceOutput,
822
+ outgoing.targetInput || 0
823
+ );
824
+ }
825
+ });
826
+ });
827
+
828
+ // Now delete the node (which removes its connections)
829
+ deleteNode(nodeId);
830
+ });
831
+ }
832
+
833
+ export function updateNodeOutputCount(nodeId, outputCount) {
834
+ const nodeData = state.nodes.get(nodeId);
835
+ if (!nodeData) return;
836
+
837
+ // Update the output count
838
+ nodeData.outputCount = outputCount;
839
+
840
+ // Re-render the node
841
+ const nodeEl = document.getElementById(`node-${nodeId}`);
842
+ if (nodeEl) {
843
+ nodeEl.remove();
844
+ renderNode(nodeData);
845
+ updateConnections();
846
+ }
847
+ }
848
+
849
+ export function updateNodeInputCount(nodeId, inputCount) {
850
+ const nodeData = state.nodes.get(nodeId);
851
+ if (!nodeData) return;
852
+
853
+ // Update the input count
854
+ nodeData.inputCount = inputCount;
855
+
856
+ // Re-render the node
857
+ const nodeEl = document.getElementById(`node-${nodeId}`);
858
+ if (nodeEl) {
859
+ nodeEl.remove();
860
+ renderNode(nodeData);
861
+ updateConnections();
862
+ }
863
+ }
864
+
865
+ // Unified node action handler
866
+ window.nodeAction = async function(nodeId, action, value) {
867
+ try {
868
+ // Handle different action types
869
+ if (action === 'inject') {
870
+ await fetch(`${API_BASE}/nodes/${nodeId}/inject`, { method: 'POST' });
871
+ } else if (action === 'toggle_gate') {
872
+ await window.toggleGate(nodeId, value);
873
+ } else if (action === 'toggle_debug') {
874
+ await window.toggleDebug(nodeId, value);
875
+ } else if (action === 'toggle_drawing') {
876
+ await window.toggleDrawPredictions(nodeId, value);
877
+ } else {
878
+ // Generic action - call the action endpoint. When a value is
879
+ // supplied (e.g. a seek target), send it as a JSON body so the
880
+ // backend action can receive it.
881
+ const options = { method: 'POST' };
882
+ if (value !== undefined) {
883
+ options.headers = { 'Content-Type': 'application/json' };
884
+ options.body = JSON.stringify({ value });
885
+ }
886
+ await fetch(`${API_BASE}/nodes/${nodeId}/${action}`, options);
887
+ }
888
+ } catch (error) {
889
+ console.error(`Failed to execute action ${action} on node ${nodeId}:`, error);
890
+ }
891
+ };
892
+
893
+ // Preview the video position label while dragging the progress slider.
894
+ // Marks the slider as being scrubbed so incoming SSE position updates don't
895
+ // fight the user's drag (see updateVideoPosition in debug.js).
896
+ window.videoSeekPreview = function(nodeId, value) {
897
+ const sliderEl = document.getElementById(`transport-progress-${nodeId}`);
898
+ if (sliderEl) sliderEl.dataset.seeking = 'true';
899
+ const posEl = document.getElementById(`transport-pos-${nodeId}`);
900
+ if (posEl) {
901
+ const frame = parseInt(value, 10) || 0;
902
+ const max = sliderEl ? (parseInt(sliderEl.max, 10) || 0) : 0;
903
+ const total = max > 0 ? max + 1 : 0;
904
+ // Zero-pad to the total's digit count so the node width stays constant.
905
+ const current = frame + 1;
906
+ const currentText = total > 0
907
+ ? String(current).padStart(String(total).length, '0')
908
+ : String(current);
909
+ posEl.textContent = `${currentText}/${total > 0 ? total : '?'}`;
910
+ }
911
+ };
912
+
913
+ // Commit a seek when the user releases the progress slider.
914
+ window.videoSeek = async function(nodeId, value) {
915
+ const sliderEl = document.getElementById(`transport-progress-${nodeId}`);
916
+ if (sliderEl) sliderEl.dataset.seeking = 'false';
917
+ await window.nodeAction(nodeId, 'seek', parseInt(value, 10) || 0);
918
+ };
919
+
920
+ // Toggle draw predictions enabled state
921
+ window.toggleDrawPredictions = async function(nodeId, enabled) {
922
+ try {
923
+ // Call the toggle_drawing action on the backend
924
+ const response = await fetch(`${API_BASE}/nodes/${nodeId}/toggle_drawing`, {
925
+ method: 'POST'
926
+ });
927
+
928
+ if (response.ok) {
929
+ const nodeData = state.nodes.get(nodeId);
930
+ if (nodeData) {
931
+ // Store the state in nodeData for persistence
932
+ nodeData.drawingEnabled = enabled;
933
+ }
934
+
935
+ // Sync toggle in properties panel if it exists
936
+ syncPropertiesPanelToggle(nodeId, 'drawingEnabled', enabled);
937
+
938
+ console.log(`Draw predictions ${enabled ? 'enabled' : 'disabled'} for node ${nodeId}`);
939
+ }
940
+ } catch (error) {
941
+ console.error('Failed to toggle draw predictions:', error);
942
+ }
943
+ };
944
+
945
+ /**
946
+ * Sync a toggle in the properties panel with the node's state.
947
+ * Called when a toggle on the node itself is changed.
948
+ * @param {string} nodeId - The node ID
949
+ * @param {string} stateField - The state field name (e.g., 'drawingEnabled')
950
+ * @param {boolean} enabled - The new state
951
+ */
952
+ function syncPropertiesPanelToggle(nodeId, stateField, enabled) {
953
+ // Find any toggle in the properties panel with matching state field
954
+ const propToggles = document.querySelectorAll(`#properties-panel input[type="checkbox"][data-state-field="${stateField}"]`);
955
+ propToggles.forEach(toggle => {
956
+ if (toggle.id.includes(nodeId) && toggle.checked !== enabled) {
957
+ toggle.checked = enabled;
958
+ }
959
+ });
960
+ }
961
+
962
+ // Toggle debug node enabled state - delegates to toggleNodeState
963
+ window.toggleDebug = async function(nodeId, enabled) {
964
+ // Use the consolidated toggleNodeState from properties.js
965
+ await window.toggleNodeState(nodeId, enabled);
966
+ };
967
+
968
+ // Trigger inject node
969
+ window.triggerInject = async function(nodeId) {
970
+ try {
971
+ await fetch(`${API_BASE}/nodes/${nodeId}/inject`, { method: 'POST' });
972
+ } catch (error) {
973
+ console.error('Failed to trigger inject:', error);
974
+ }
975
+ };
976
+
977
+ // Upload an image file to an ImageUploadNode
978
+ window.uploadImageToNode = async function(nodeId, file) {
979
+ try {
980
+ const formData = new FormData();
981
+ formData.append('file', file);
982
+
983
+ const response = await fetch(`${API_BASE}/nodes/${nodeId}/upload_image`, {
984
+ method: 'POST',
985
+ body: formData
986
+ });
987
+
988
+ const result = await response.json();
989
+ if (!result.success) {
990
+ console.error('Image upload failed:', result.error);
991
+ }
992
+ } catch (error) {
993
+ console.error('Failed to upload image to node:', error);
994
+ }
995
+ };