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,425 @@
1
+ """
2
+ Video Reader node - plays a video file frame-by-frame with transport
3
+ controls (play/pause, stop, step) rendered on the node itself.
4
+ """
5
+
6
+ import base64
7
+ import os
8
+ import threading
9
+ import time
10
+ from typing import Any, Dict, Optional
11
+
12
+ import cv2
13
+
14
+ from pynode.nodes.base_node import BaseNode, Info, MessageKeys
15
+
16
+ _info = Info()
17
+ _info.add_text("Reads a video file and outputs its frames as JPEG (base64) "
18
+ "messages. Playback is driven by the transport controls on the "
19
+ "node: play/pause, stop, and single-frame stepping.")
20
+ _info.add_header("Outputs")
21
+ _info.add_bullets(
22
+ ("Output 0:", "Frame message (payload.image JPEG base64) with frame index, "
23
+ "total frame count and source path."),
24
+ )
25
+ _info.add_header("Transport Controls")
26
+ _info.add_bullets(
27
+ ("⏮ Step back:", "Show the previous frame (while paused/stopped)."),
28
+ ("⏯ Play/Pause:", "Toggle playback from the current position."),
29
+ ("⏹ Stop:", "Halt playback and seek back to the first frame."),
30
+ ("⏭ Step forward:", "Show the next frame (while paused/stopped)."),
31
+ ("Progress bar:", "Drag the slider along the bottom of the node to scrub "
32
+ "to any point in the video."),
33
+ )
34
+ _info.add_header("Properties")
35
+ _info.add_bullets(
36
+ ("Video File:", "Upload a video or enter a path manually."),
37
+ ("Frame Rate:", "Playback FPS override; 0 uses the video's native rate."),
38
+ ("Loop:", "Restart from the first frame when the video ends."),
39
+ ("JPEG Quality:", "Encoding quality for the emitted frames (1-100)."),
40
+ )
41
+ _info.add_header("Output Format")
42
+ _info.add_bullets(
43
+ (f"{MessageKeys.PAYLOAD}.image:", "JPEG base64 image dict (format/encoding/data/width/height)."),
44
+ (f"{MessageKeys.PAYLOAD}.frame:", "Zero-based index of the emitted frame."),
45
+ (f"{MessageKeys.PAYLOAD}.total_frames:", "Total number of frames in the video."),
46
+ (f"{MessageKeys.PAYLOAD}.source:", "Path of the video file."),
47
+ )
48
+
49
+
50
+ class VideoReaderNode(BaseNode):
51
+ """
52
+ Video Reader node - serves frames from an uploaded video file with
53
+ play/pause/stop/step transport controls.
54
+
55
+ The playback worker thread is only started when the user presses play
56
+ (NOT in on_start); all VideoCapture access (seek + read from the playback
57
+ thread and from the step/stop actions) is guarded by a lock.
58
+ """
59
+ info = str(_info)
60
+ display_name = 'Video Reader'
61
+ icon = '🎞️'
62
+ category = 'input'
63
+ color = '#C0DEED'
64
+ border_color = '#7FA7C9'
65
+ text_color = '#000000'
66
+ input_count = 0
67
+ output_count = 1
68
+
69
+ # Transport controls rendered on the node (see 'transport-controls' in
70
+ # static/js/nodes.js); each button POSTs its action to
71
+ # /api/nodes/<id>/<action>.
72
+ ui_component = 'transport-controls'
73
+ ui_component_config = {
74
+ 'buttons': [
75
+ {'icon': '⏮', 'action': 'step_prev', 'title': 'Step back one frame'},
76
+ {'icon': '⏯', 'action': 'play_pause', 'title': 'Play / Pause'},
77
+ {'icon': '⏹', 'action': 'stop', 'title': 'Stop (back to first frame)'},
78
+ {'icon': '⏭', 'action': 'step_next', 'title': 'Step forward one frame'},
79
+ ],
80
+ }
81
+
82
+ # UI-triggerable actions (see BaseNode.actions)
83
+ actions = ['play_pause', 'stop', 'step_prev', 'step_next', 'seek']
84
+
85
+ # get_position_sse does its own change-detection (returns None when the
86
+ # position/playing state is unchanged), so the throttle just bounds the
87
+ # comparison rate.
88
+ sse_handlers = [
89
+ {'type': 'video_position', 'handler': 'get_position_sse', 'throttle': 0.2},
90
+ ]
91
+
92
+ api_routes = [
93
+ {
94
+ 'route': 'upload_video',
95
+ 'methods': ['POST'],
96
+ 'handler': 'handle_upload_video',
97
+ 'type': 'file_upload',
98
+ 'allowed_extensions': {'.mp4', '.avi', '.mov', '.mkv', '.webm'},
99
+ },
100
+ ]
101
+
102
+ DEFAULT_CONFIG = {
103
+ MessageKeys.VIDEO.SOURCE: '',
104
+ MessageKeys.CAMERA.FPS: 0,
105
+ MessageKeys.VIDEO.LOOP: False,
106
+ MessageKeys.CAMERA.JPEG_QUALITY: 80,
107
+ }
108
+
109
+ properties = [
110
+ {
111
+ 'name': MessageKeys.VIDEO.SOURCE,
112
+ 'label': 'Video File',
113
+ 'type': 'file',
114
+ 'accept': '.mp4,.avi,.mov,.mkv,.webm',
115
+ 'uploadRoute': 'upload_video',
116
+ 'placeholder': 'Upload or enter video file path...',
117
+ },
118
+ {
119
+ 'name': MessageKeys.CAMERA.FPS,
120
+ 'label': 'Frame Rate (FPS, 0 = native)',
121
+ 'type': 'number',
122
+ 'default': DEFAULT_CONFIG[MessageKeys.CAMERA.FPS],
123
+ 'help': "Playback rate override; 0 uses the video's native FPS",
124
+ },
125
+ {
126
+ 'name': MessageKeys.VIDEO.LOOP,
127
+ 'label': 'Loop',
128
+ 'type': 'checkbox',
129
+ 'default': DEFAULT_CONFIG[MessageKeys.VIDEO.LOOP],
130
+ },
131
+ {
132
+ 'name': MessageKeys.CAMERA.JPEG_QUALITY,
133
+ 'label': 'JPEG Quality (1-100)',
134
+ 'type': 'number',
135
+ 'default': DEFAULT_CONFIG[MessageKeys.CAMERA.JPEG_QUALITY],
136
+ },
137
+ ]
138
+
139
+ def __init__(self, node_id=None, name="video reader"):
140
+ super().__init__(node_id, name)
141
+ # VideoCapture state - every access goes through _cap_lock because
142
+ # the playback thread and the transport actions (stop/step) both
143
+ # seek/read the capture.
144
+ self._cap: Optional[cv2.VideoCapture] = None
145
+ self._cap_lock = threading.RLock()
146
+ self._video_path: Optional[str] = None
147
+ self._total_frames = 0
148
+ self._native_fps = 0.0
149
+ # Index of the NEXT frame to read (the displayed frame is one less).
150
+ self._frame_index = 0
151
+ # Playback thread state; transport actions are serialized by
152
+ # _transport_lock so play/stop/step can't interleave.
153
+ self._playing = False
154
+ self._play_thread: Optional[threading.Thread] = None
155
+ self._transport_lock = threading.Lock()
156
+ # Last position broadcast via SSE (change detection).
157
+ self._last_sse_state = None
158
+
159
+ # ------------------------------------------------------------------
160
+ # Upload route
161
+ # ------------------------------------------------------------------
162
+
163
+ def handle_upload_video(self, file_bytes, filename):
164
+ """Handle video file upload via the dynamic API route.
165
+
166
+ Follows the FrameSourceNode convention: files are saved to a
167
+ 'videos' directory next to this node module.
168
+ """
169
+ try:
170
+ videos_dir = os.path.join(os.path.dirname(__file__), 'videos')
171
+ os.makedirs(videos_dir, exist_ok=True)
172
+
173
+ file_path = os.path.join(videos_dir, os.path.basename(filename))
174
+ with open(file_path, 'wb') as f:
175
+ f.write(file_bytes)
176
+
177
+ return {
178
+ 'success': True,
179
+ 'file_path': file_path,
180
+ 'filename': filename,
181
+ }
182
+ except Exception as e:
183
+ return {'success': False, 'error': str(e)}
184
+
185
+ # ------------------------------------------------------------------
186
+ # Lifecycle
187
+ # ------------------------------------------------------------------
188
+
189
+ def on_start(self):
190
+ """Start the base worker thread only - playback waits for play."""
191
+ super().on_start()
192
+
193
+ def on_stop(self):
194
+ """Halt playback and release the capture when the workflow stops."""
195
+ super().on_stop()
196
+ self._halt_playback()
197
+ with self._cap_lock:
198
+ self._release_capture()
199
+
200
+ def on_close(self):
201
+ """Cleanup when node is deleted."""
202
+ self.on_stop()
203
+
204
+ # ------------------------------------------------------------------
205
+ # Transport actions (declared in `actions`)
206
+ # ------------------------------------------------------------------
207
+
208
+ def play_pause(self):
209
+ """Toggle playback: pause if playing, otherwise play from the
210
+ current position (from the start if at the end and loop is off)."""
211
+ with self._transport_lock:
212
+ if self._playing:
213
+ self._halt_playback()
214
+ return
215
+ if not self._ensure_capture():
216
+ return
217
+ with self._cap_lock:
218
+ # Pressing play at the end restarts from the first frame.
219
+ if self._total_frames and self._frame_index >= self._total_frames:
220
+ self._frame_index = 0
221
+ self._playing = True
222
+ self._play_thread = threading.Thread(target=self._playback_loop,
223
+ daemon=True)
224
+ self._play_thread.start()
225
+
226
+ def stop(self):
227
+ """Halt playback and seek back to the first frame."""
228
+ with self._transport_lock:
229
+ self._halt_playback()
230
+ with self._cap_lock:
231
+ if self._cap is not None and self._cap.isOpened():
232
+ self._cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
233
+ self._frame_index = 0
234
+
235
+ def step_next(self):
236
+ """Emit the next frame (only meaningful while paused/stopped)."""
237
+ with self._transport_lock:
238
+ if self._playing:
239
+ return
240
+ if not self._ensure_capture():
241
+ return
242
+ index = self._frame_index
243
+ if self._total_frames and index >= self._total_frames:
244
+ if not self.get_config_bool(MessageKeys.VIDEO.LOOP, False):
245
+ return # at the end, no loop -> nothing to step to
246
+ index = 0
247
+ self._emit_frame_at(index)
248
+
249
+ def step_prev(self):
250
+ """Emit the previous frame (only meaningful while paused/stopped)."""
251
+ with self._transport_lock:
252
+ if self._playing:
253
+ return
254
+ if not self._ensure_capture():
255
+ return
256
+ displayed = self._frame_index - 1
257
+ target = max(0, displayed - 1)
258
+ self._emit_frame_at(target)
259
+
260
+ def seek(self, target):
261
+ """Seek to a specific frame index (driven by the progress slider)."""
262
+ with self._transport_lock:
263
+ if not self._ensure_capture():
264
+ return
265
+ try:
266
+ index = int(target)
267
+ except (TypeError, ValueError):
268
+ return
269
+ with self._cap_lock:
270
+ if self._total_frames:
271
+ index = max(0, min(index, self._total_frames - 1))
272
+ else:
273
+ index = max(0, index)
274
+ if self._playing:
275
+ # The playback loop reads _frame_index each iteration and
276
+ # re-seeks the capture when it differs; just reposition and
277
+ # let the loop emit from the new spot.
278
+ self._frame_index = index
279
+ return
280
+ # Paused/stopped: show the requested frame immediately.
281
+ self._emit_frame_at(index)
282
+
283
+ # ------------------------------------------------------------------
284
+ # SSE position reporting
285
+ # ------------------------------------------------------------------
286
+
287
+ def get_position_sse(self):
288
+ """SSE handler: report {frame, total, playing} only on change."""
289
+ frame = max(0, self._frame_index - 1)
290
+ sse_state = (frame, self._total_frames, self._playing)
291
+ if sse_state == self._last_sse_state:
292
+ return None
293
+ self._last_sse_state = sse_state
294
+ return {
295
+ 'frame': frame,
296
+ 'total': self._total_frames,
297
+ 'playing': self._playing,
298
+ }
299
+
300
+ # ------------------------------------------------------------------
301
+ # Internals
302
+ # ------------------------------------------------------------------
303
+
304
+ def _release_capture(self):
305
+ """Release the VideoCapture. Caller must hold _cap_lock."""
306
+ if self._cap is not None:
307
+ try:
308
+ self._cap.release()
309
+ except Exception:
310
+ pass
311
+ self._cap = None
312
+ self._video_path = None
313
+
314
+ def _ensure_capture(self) -> bool:
315
+ """Open (or re-open on path change) the configured video file."""
316
+ path = str(self.config.get(MessageKeys.VIDEO.SOURCE, '') or '').strip()
317
+ if not path:
318
+ self.report_error("No video file configured")
319
+ return False
320
+ with self._cap_lock:
321
+ if (self._cap is not None and self._cap.isOpened()
322
+ and path == self._video_path):
323
+ return True
324
+ self._release_capture()
325
+ cap = cv2.VideoCapture(path)
326
+ if not cap.isOpened():
327
+ self.report_error(f"Failed to open video: {path}")
328
+ return False
329
+ self._cap = cap
330
+ self._video_path = path
331
+ self._total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
332
+ self._native_fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0)
333
+ self._frame_index = 0
334
+ return True
335
+
336
+ def _halt_playback(self):
337
+ """Stop the playback thread and join it (never joins itself)."""
338
+ self._playing = False
339
+ thread = self._play_thread
340
+ if (thread is not None and thread.is_alive()
341
+ and thread is not threading.current_thread()):
342
+ thread.join(timeout=2.0)
343
+ self._play_thread = None
344
+
345
+ def _emit_frame_at(self, index: int) -> bool:
346
+ """Seek to `index` (if needed), read one frame and send it."""
347
+ with self._cap_lock:
348
+ cap = self._cap
349
+ if cap is None or not cap.isOpened():
350
+ return False
351
+ if int(cap.get(cv2.CAP_PROP_POS_FRAMES)) != index:
352
+ cap.set(cv2.CAP_PROP_POS_FRAMES, index)
353
+ ret, frame = cap.read()
354
+ if not ret or frame is None:
355
+ return False
356
+ self._frame_index = index + 1
357
+ total = self._total_frames
358
+ path = self._video_path
359
+
360
+ quality = self.get_config_int(MessageKeys.CAMERA.JPEG_QUALITY, 80)
361
+ ok, buffer = cv2.imencode('.jpg', frame,
362
+ (cv2.IMWRITE_JPEG_QUALITY, quality))
363
+ if not ok:
364
+ self.report_error("Failed to encode frame as JPEG")
365
+ return False
366
+ jpeg_base64 = base64.b64encode(buffer.tobytes()).decode('utf-8')
367
+
368
+ image_payload = {
369
+ MessageKeys.IMAGE.FORMAT: 'jpeg',
370
+ MessageKeys.IMAGE.ENCODING: 'base64',
371
+ MessageKeys.IMAGE.DATA: jpeg_base64,
372
+ MessageKeys.IMAGE.WIDTH: frame.shape[1],
373
+ MessageKeys.IMAGE.HEIGHT: frame.shape[0],
374
+ }
375
+ message_payload: Dict[str, Any] = {
376
+ MessageKeys.IMAGE.PATH: image_payload,
377
+ 'frame': index,
378
+ 'total_frames': total,
379
+ MessageKeys.VIDEO.SOURCE: path,
380
+ }
381
+ msg = self.create_message(payload=message_payload,
382
+ topic='video/frame', frame_count=index)
383
+ self.send(msg)
384
+ return True
385
+
386
+ def _playback_loop(self):
387
+ """Worker thread: emit frames paced to the (native or override) FPS."""
388
+ fps = self.get_config_float(MessageKeys.CAMERA.FPS, 0)
389
+ if fps <= 0:
390
+ fps = self._native_fps
391
+ if fps <= 0:
392
+ fps = 30.0
393
+ frame_interval = 1.0 / fps
394
+ loop = self.get_config_bool(MessageKeys.VIDEO.LOOP, False)
395
+
396
+ while self._playing:
397
+ start_time = time.time()
398
+
399
+ with self._cap_lock:
400
+ if self._cap is None or not self._cap.isOpened():
401
+ self._playing = False
402
+ break
403
+ index = self._frame_index
404
+ if self._total_frames and index >= self._total_frames:
405
+ if loop:
406
+ index = 0
407
+ else:
408
+ # End of video, no loop: stop emitting; position
409
+ # remains at the last frame.
410
+ self._playing = False
411
+ break
412
+
413
+ if not self._emit_frame_at(index):
414
+ # Read failure (or end reached without a frame count).
415
+ if loop and self._total_frames:
416
+ with self._cap_lock:
417
+ self._frame_index = 0
418
+ continue
419
+ self._playing = False
420
+ break
421
+
422
+ elapsed = time.time() - start_time
423
+ sleep_time = max(0.0, frame_interval - elapsed)
424
+ if sleep_time > 0:
425
+ time.sleep(sleep_time)
@@ -0,0 +1,3 @@
1
+ from .video_writer_node import VideoWriterNode
2
+
3
+ __all__ = ['VideoWriterNode']