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.
- pynode/__init__.py +37 -0
- pynode/__main__.py +9 -0
- pynode/_perf.py +65 -0
- pynode/_version.py +24 -0
- pynode/api/__init__.py +15 -0
- pynode/api/helpers.py +79 -0
- pynode/api/nodes.py +423 -0
- pynode/api/services.py +124 -0
- pynode/api/sse.py +58 -0
- pynode/api/uploads.py +59 -0
- pynode/api/workflows.py +466 -0
- pynode/config.py +93 -0
- pynode/install_nodes.py +143 -0
- pynode/main.py +118 -0
- pynode/node_registry.py +156 -0
- pynode/nodes/AutoSyncNode/auto_sync_node.py +151 -0
- pynode/nodes/BBoxMetricsNode/__init__.py +3 -0
- pynode/nodes/BBoxMetricsNode/bbox_metrics_node.py +250 -0
- pynode/nodes/BBoxMetricsNode/requirements.txt +1 -0
- pynode/nodes/BatchNode/__init__.py +3 -0
- pynode/nodes/BatchNode/batch_node.py +86 -0
- pynode/nodes/BatchNode/requirements.txt +1 -0
- pynode/nodes/CameraNode/__init__.py +4 -0
- pynode/nodes/CameraNode/camera_node.py +204 -0
- pynode/nodes/CameraNode/requirements.txt +2 -0
- pynode/nodes/ChangeNode/__init__.py +1 -0
- pynode/nodes/ChangeNode/change_node.py +255 -0
- pynode/nodes/ChangeNode/requirements.txt +1 -0
- pynode/nodes/ConfidenceFilterNode/__init__.py +4 -0
- pynode/nodes/ConfidenceFilterNode/confidence_filter_node.py +208 -0
- pynode/nodes/ConfidenceFilterNode/requirements.txt +1 -0
- pynode/nodes/CounterNode/__init__.py +3 -0
- pynode/nodes/CounterNode/counter_node.py +191 -0
- pynode/nodes/CropNode/__init__.py +4 -0
- pynode/nodes/CropNode/crop_node.py +317 -0
- pynode/nodes/CropNode/requirements.txt +2 -0
- pynode/nodes/DebugNode/__init__.py +4 -0
- pynode/nodes/DebugNode/debug_node.py +169 -0
- pynode/nodes/DebugNode/requirements.txt +1 -0
- pynode/nodes/DelayNode/delay_node.py +218 -0
- pynode/nodes/DelayNode/requirements.txt +1 -0
- pynode/nodes/DrawPredictionsNode/__init__.py +3 -0
- pynode/nodes/DrawPredictionsNode/draw_predictions_node.py +365 -0
- pynode/nodes/DrawPredictionsNode/requirements.txt +2 -0
- pynode/nodes/ErrorNode/error_node.py +94 -0
- pynode/nodes/ErrorNode/requirements.txt +1 -0
- pynode/nodes/FilterNode/__init__.py +3 -0
- pynode/nodes/FilterNode/filter_node.py +102 -0
- pynode/nodes/FilterNode/requirements.txt +1 -0
- pynode/nodes/FrameSourceNode/__init__.py +4 -0
- pynode/nodes/FrameSourceNode/frame_source_node.py +319 -0
- pynode/nodes/FrameSourceNode/requirements.txt +1 -0
- pynode/nodes/FunctionNode/__init__.py +3 -0
- pynode/nodes/FunctionNode/function_node.py +168 -0
- pynode/nodes/FunctionNode/requirements.txt +1 -0
- pynode/nodes/GateNode/gate_node.py +60 -0
- pynode/nodes/GateNode/requirements.txt +1 -0
- pynode/nodes/GetiUploadNode/__init__.py +3 -0
- pynode/nodes/GetiUploadNode/geti_upload_node.py +278 -0
- pynode/nodes/GetiUploadNode/requirements.txt +1 -0
- pynode/nodes/ImageFormatNode/__init__.py +4 -0
- pynode/nodes/ImageFormatNode/image_format_node.py +310 -0
- pynode/nodes/ImageFormatNode/requirements.txt +2 -0
- pynode/nodes/ImageUploadNode/image_upload_node.py +96 -0
- pynode/nodes/ImageViewerNode/__init__.py +4 -0
- pynode/nodes/ImageViewerNode/image_viewer_node.py +162 -0
- pynode/nodes/ImageViewerNode/requirements.txt +2 -0
- pynode/nodes/ImageWriterNode/__init__.py +3 -0
- pynode/nodes/ImageWriterNode/imagewriter_node.py +352 -0
- pynode/nodes/ImageWriterNode/requirements.txt +1 -0
- pynode/nodes/InferenceNode/InferenceEngine/__init__.py +15 -0
- pynode/nodes/InferenceNode/InferenceEngine/device_detection.py +149 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/base_engine.py +155 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/example_engine_template.py +272 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/geti_engine.py +309 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/onnx_engine.py +346 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/pass_engine.py +86 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/ultralytics_engine.py +863 -0
- pynode/nodes/InferenceNode/InferenceEngine/inference_engine_factory.py +533 -0
- pynode/nodes/InferenceNode/InferenceEngine/readme.md +107 -0
- pynode/nodes/InferenceNode/InferenceEngine/result_converters.py +121 -0
- pynode/nodes/InferenceNode/inference_node.py +451 -0
- pynode/nodes/InferenceNode/requirements.txt +13 -0
- pynode/nodes/InjectNode/inject_node.py +245 -0
- pynode/nodes/InjectNode/requirements.txt +1 -0
- pynode/nodes/JoinNode/__init__.py +3 -0
- pynode/nodes/JoinNode/join_node.py +156 -0
- pynode/nodes/JoinNode/requirements.txt +1 -0
- pynode/nodes/LabelFilterNode/__init__.py +3 -0
- pynode/nodes/LabelFilterNode/label_filter_node.py +188 -0
- pynode/nodes/LabelFilterNode/requirements.txt +1 -0
- pynode/nodes/MQTTNode/__init__.py +8 -0
- pynode/nodes/MQTTNode/mqtt_in_node.py +148 -0
- pynode/nodes/MQTTNode/mqtt_out_node.py +157 -0
- pynode/nodes/MQTTNode/mqtt_service.py +367 -0
- pynode/nodes/MQTTNode/requirements.txt +1 -0
- pynode/nodes/MessageWriterNode/__init__.py +7 -0
- pynode/nodes/MessageWriterNode/messagereader_node.py +568 -0
- pynode/nodes/MessageWriterNode/messagewriter_node.py +505 -0
- pynode/nodes/OmronCameraNode/__init__.py +3 -0
- pynode/nodes/OmronCameraNode/omron_camera_node.py +652 -0
- pynode/nodes/OmronCameraNode/requirements.txt +4 -0
- pynode/nodes/OpenCV/__init__.py +1 -0
- pynode/nodes/OpenCV/arithmetic_node.py +376 -0
- pynode/nodes/OpenCV/bitwise_node.py +127 -0
- pynode/nodes/OpenCV/blend_node.py +127 -0
- pynode/nodes/OpenCV/blob_detector_node.py +233 -0
- pynode/nodes/OpenCV/blur_node.py +141 -0
- pynode/nodes/OpenCV/color_space_node.py +139 -0
- pynode/nodes/OpenCV/colormap_node.py +262 -0
- pynode/nodes/OpenCV/contours_node.py +258 -0
- pynode/nodes/OpenCV/denormalize_coords_node.py +365 -0
- pynode/nodes/OpenCV/draw_node.py +312 -0
- pynode/nodes/OpenCV/edge_detector_node.py +177 -0
- pynode/nodes/OpenCV/equirectangular_node.py +447 -0
- pynode/nodes/OpenCV/fft_node.py +102 -0
- pynode/nodes/OpenCV/focus_estimation_node.py +189 -0
- pynode/nodes/OpenCV/frequency_filter_node.py +241 -0
- pynode/nodes/OpenCV/histogram_node.py +160 -0
- pynode/nodes/OpenCV/hough_circles_node.py +224 -0
- pynode/nodes/OpenCV/hough_lines_node.py +243 -0
- pynode/nodes/OpenCV/in_range_node.py +186 -0
- pynode/nodes/OpenCV/morphology_node.py +136 -0
- pynode/nodes/OpenCV/normalize_coords_node.py +344 -0
- pynode/nodes/OpenCV/paste_node.py +244 -0
- pynode/nodes/OpenCV/perspective_node.py +174 -0
- pynode/nodes/OpenCV/realsense_depth_node.py +253 -0
- pynode/nodes/OpenCV/requirements.txt +2 -0
- pynode/nodes/OpenCV/resize_node.py +175 -0
- pynode/nodes/OpenCV/rotate_node.py +159 -0
- pynode/nodes/OpenCV/template_match_node.py +227 -0
- pynode/nodes/OpenCV/threshold_node.py +167 -0
- pynode/nodes/PointInShapeNode/__init__.py +3 -0
- pynode/nodes/PointInShapeNode/point_in_shape_node.py +313 -0
- pynode/nodes/PointInShapeNode/requirements.txt +2 -0
- pynode/nodes/PolygonMetricsNode/__init__.py +3 -0
- pynode/nodes/PolygonMetricsNode/polygon_metrics_node.py +331 -0
- pynode/nodes/PolygonMetricsNode/requirements.txt +2 -0
- pynode/nodes/QueueLengthProbeNode/__init__.py +3 -0
- pynode/nodes/QueueLengthProbeNode/queue_length_probe_node.py +116 -0
- pynode/nodes/Qwen3VLMNode/__init__.py +3 -0
- pynode/nodes/Qwen3VLMNode/qwen3_vlm_node.py +519 -0
- pynode/nodes/Qwen3VLMNode/requirements.txt +5 -0
- pynode/nodes/RESTEndpointNode/__init__.py +3 -0
- pynode/nodes/RESTEndpointNode/rest_endpoint_node.py +491 -0
- pynode/nodes/RangeNode/__init__.py +3 -0
- pynode/nodes/RangeNode/range_node.py +115 -0
- pynode/nodes/RangeNode/requirements.txt +1 -0
- pynode/nodes/RateProbeNode/__init__.py +3 -0
- pynode/nodes/RateProbeNode/rate_probe_node.py +183 -0
- pynode/nodes/RateProbeNode/requirements.txt +1 -0
- pynode/nodes/RoboflowUploadNode/__init__.py +3 -0
- pynode/nodes/RoboflowUploadNode/requirements.txt +1 -0
- pynode/nodes/RoboflowUploadNode/roboflow_upload_node.py +229 -0
- pynode/nodes/SliceNode/README.md +149 -0
- pynode/nodes/SliceNode/__init__.py +5 -0
- pynode/nodes/SliceNode/merge_predictions_node.py +444 -0
- pynode/nodes/SliceNode/requirements.txt +3 -0
- pynode/nodes/SliceNode/slice_collector_node.py +370 -0
- pynode/nodes/SliceNode/slice_image_node.py +380 -0
- pynode/nodes/SplitNode/__init__.py +3 -0
- pynode/nodes/SplitNode/requirements.txt +1 -0
- pynode/nodes/SplitNode/split_node.py +155 -0
- pynode/nodes/SwitchNode/requirements.txt +1 -0
- pynode/nodes/SwitchNode/switch_node.py +259 -0
- pynode/nodes/SyncNode/__init__.py +3 -0
- pynode/nodes/SyncNode/sync_node.py +287 -0
- pynode/nodes/TemplateNode/requirements.txt +1 -0
- pynode/nodes/TemplateNode/template_node.py +86 -0
- pynode/nodes/TrackerNode/__init__.py +2 -0
- pynode/nodes/TrackerNode/supervision_tracker_node.py +248 -0
- pynode/nodes/TrackerNode/tracker_node.py +745 -0
- pynode/nodes/UltralyticsNode/__init__.py +4 -0
- pynode/nodes/UltralyticsNode/requirements.txt +2 -0
- pynode/nodes/UltralyticsNode/ultralytics_node.py +364 -0
- pynode/nodes/UnknownNode/__init__.py +93 -0
- pynode/nodes/VLLMNode/__init__.py +3 -0
- pynode/nodes/VLLMNode/vllm_node.py +345 -0
- pynode/nodes/VideoReaderNode/__init__.py +3 -0
- pynode/nodes/VideoReaderNode/video_reader_node.py +425 -0
- pynode/nodes/VideoWriterNode/__init__.py +3 -0
- pynode/nodes/VideoWriterNode/video_writer_node.py +441 -0
- pynode/nodes/WebhookNode/__init__.py +3 -0
- pynode/nodes/WebhookNode/webhook_node.py +591 -0
- pynode/nodes/__init__.py +109 -0
- pynode/nodes/base_node.py +562 -0
- pynode/nodes/image_utils.py +291 -0
- pynode/nodes/info.py +109 -0
- pynode/nodes/mDNSNode/__init__.py +13 -0
- pynode/nodes/mDNSNode/mdns_broadcast_node.py +214 -0
- pynode/nodes/mDNSNode/mdns_discovery_node.py +362 -0
- pynode/nodes/mDNSNode/mdns_manager.py +399 -0
- pynode/nodes/mDNSNode/requirements.txt +1 -0
- pynode/nodes/messages.py +81 -0
- pynode/server.py +187 -0
- pynode/static/images/favicon4.png +0 -0
- pynode/static/index.html +200 -0
- pynode/static/js/README.md +139 -0
- pynode/static/js/auth.js +89 -0
- pynode/static/js/clipboard.js +152 -0
- pynode/static/js/config.js +14 -0
- pynode/static/js/connections.js +844 -0
- pynode/static/js/debug.js +524 -0
- pynode/static/js/events.js +775 -0
- pynode/static/js/history.js +199 -0
- pynode/static/js/main.js +55 -0
- pynode/static/js/minimap.js +242 -0
- pynode/static/js/nodes.js +995 -0
- pynode/static/js/palette.js +255 -0
- pynode/static/js/properties.js +1270 -0
- pynode/static/js/selection.js +241 -0
- pynode/static/js/state.js +211 -0
- pynode/static/js/ui-utils.js +28 -0
- pynode/static/js/viewport.js +204 -0
- pynode/static/js/workflow.js +469 -0
- pynode/static/js/workflows.js +344 -0
- pynode/static/style.css +2751 -0
- pynode/workflow_engine.py +483 -0
- pynode/workflow_manager.py +440 -0
- pynode_flow-0.2.0.dist-info/METADATA +520 -0
- pynode_flow-0.2.0.dist-info/RECORD +227 -0
- pynode_flow-0.2.0.dist-info/WHEEL +5 -0
- pynode_flow-0.2.0.dist-info/entry_points.txt +3 -0
- pynode_flow-0.2.0.dist-info/licenses/LICENSE +21 -0
- pynode_flow-0.2.0.dist-info/scm_file_list.json +270 -0
- pynode_flow-0.2.0.dist-info/scm_version.json +8 -0
- 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
|
+
};
|