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,441 @@
1
+ """
2
+ Video Writer Node - Writes video files from incoming image frames
3
+ """
4
+
5
+ import os
6
+ import time
7
+ import cv2
8
+ import numpy as np
9
+ from datetime import datetime
10
+ from pynode.nodes.base_node import BaseNode, Info, MessageKeys
11
+
12
+ _info = Info()
13
+ _info.add_text("Records video files from incoming image frames. Automatically starts recording when frames arrive and supports automatic file rollover based on clip length.")
14
+ _info.add_header("Inputs")
15
+ _info.add_bullets(
16
+ ("Input 0:", "Message with image data at payload.image")
17
+ )
18
+ _info.add_header("Outputs")
19
+ _info.add_bullets(
20
+ ("Output 0:", "Recording status events (recording_start, recording_end)")
21
+ )
22
+ _info.add_header("Supported Codecs")
23
+ _info.add_bullets(
24
+ ("MPEG-4:", ".mp4 - Good compatibility, moderate compression"),
25
+ ("H.264/AVC:", ".mp4 - Best compression, wide support"),
26
+ ("Motion JPEG:", ".avi - Each frame is a JPEG, large files"),
27
+ ("Xvid:", ".avi - Good compression, legacy format"),
28
+ ("DivX:", ".avi - Similar to Xvid"),
29
+ ("WMV:", ".wmv - Windows Media format")
30
+ )
31
+ _info.add_header("Naming Modes")
32
+ _info.add_bullets(
33
+ ("Counter:", "Sequential numbering (video_0001.mp4, video_0002.mp4, ...)"),
34
+ ("Timestamp:", "Unix timestamp (video_1767375877.mp4)"),
35
+ ("DateTime:", "Date/time format (video_20260102_174437.mp4)"),
36
+ ("Message:", "From msg.filename field")
37
+ )
38
+ _info.add_header("Resize Methods")
39
+ _info.add_bullets(
40
+ ("Fit:", "Scales to fit within resolution, adds letterbox bars"),
41
+ ("Fill:", "Scales to fill resolution, crops edges"),
42
+ ("Stretch:", "Stretches to exact resolution, ignores aspect ratio")
43
+ )
44
+ _info.add_header("Output Events")
45
+ _info.add_text("When recording starts:")
46
+ _info.add_code('{ "event": "recording_start", "filename": "...", "width": 1920, "height": 1080, "framerate": 30, "codec": "mp4v" }')
47
+ _info.add_text("When recording ends:")
48
+ _info.add_code('{ "event": "recording_end", "filename": "...", "frames": 900 }')
49
+
50
+
51
+ class VideoWriterNode(BaseNode):
52
+ """Writes video files from incoming image frames with various output options"""
53
+
54
+ display_name = "Video Writer"
55
+ category = "output"
56
+ icon = "🎥"
57
+ color = "#87A980"
58
+ border_color = '#5F7858'
59
+ text_color = '#000000'
60
+ input_count = 1
61
+ output_count = 1 # Output for recording start/end events
62
+ info = str(_info)
63
+
64
+ DEFAULT_CONFIG = {
65
+ 'path': './output',
66
+ 'filename': 'video_{counter}',
67
+ 'codec': 'mp4v',
68
+ 'framerate': 30.0,
69
+ 'width': 1920,
70
+ 'height': 1080,
71
+ 'clip_length': 0, # 0 = unlimited
72
+ 'naming_mode': 'counter',
73
+ 'counter_digits': 4,
74
+ 'resize_method': 'fit',
75
+ 'auto_resolution': False,
76
+ }
77
+
78
+ # Available codecs with their file extensions
79
+ CODECS = {
80
+ 'mp4v': {'fourcc': 'mp4v', 'ext': '.mp4', 'name': 'MPEG-4'},
81
+ 'avc1': {'fourcc': 'avc1', 'ext': '.mp4', 'name': 'H.264 (AVC)'},
82
+ 'xvid': {'fourcc': 'XVID', 'ext': '.avi', 'name': 'Xvid'},
83
+ 'mjpg': {'fourcc': 'MJPG', 'ext': '.avi', 'name': 'Motion JPEG'},
84
+ 'divx': {'fourcc': 'DIVX', 'ext': '.avi', 'name': 'DivX'},
85
+ 'wmv1': {'fourcc': 'WMV1', 'ext': '.wmv', 'name': 'WMV'},
86
+ }
87
+
88
+ properties = [
89
+ {
90
+ 'name': 'path',
91
+ 'label': 'Output Path',
92
+ 'type': 'text',
93
+ 'default': DEFAULT_CONFIG['path'],
94
+ 'placeholder': DEFAULT_CONFIG['path']
95
+ },
96
+ {
97
+ 'name': 'filename',
98
+ 'label': 'Filename',
99
+ 'type': 'text',
100
+ 'default': DEFAULT_CONFIG['filename'],
101
+ 'placeholder': DEFAULT_CONFIG['filename']
102
+ },
103
+ {
104
+ 'name': 'naming_mode',
105
+ 'label': 'Naming Mode',
106
+ 'type': 'select',
107
+ 'options': [
108
+ {'value': 'counter', 'label': 'Counter (0001, 0002, ...)'},
109
+ {'value': 'timestamp', 'label': 'Unix Timestamp'},
110
+ {'value': 'datetime', 'label': 'Date/Time (YYYYMMDD_HHMMSS)'},
111
+ {'value': 'message', 'label': 'From msg.filename'}
112
+ ],
113
+ 'default': DEFAULT_CONFIG['naming_mode']
114
+ },
115
+ {
116
+ 'name': 'counter_digits',
117
+ 'label': 'Counter Digits',
118
+ 'type': 'number',
119
+ 'default': DEFAULT_CONFIG['counter_digits'],
120
+ 'min': 1,
121
+ 'max': 10
122
+ },
123
+ {
124
+ 'name': 'codec',
125
+ 'label': 'Codec',
126
+ 'type': 'select',
127
+ 'options': [
128
+ {'value': 'mp4v', 'label': 'MPEG-4 (.mp4)'},
129
+ {'value': 'avc1', 'label': 'H.264/AVC (.mp4)'},
130
+ {'value': 'xvid', 'label': 'Xvid (.avi)'},
131
+ {'value': 'mjpg', 'label': 'Motion JPEG (.avi)'},
132
+ {'value': 'divx', 'label': 'DivX (.avi)'},
133
+ {'value': 'wmv1', 'label': 'WMV (.wmv)'},
134
+ ],
135
+ 'default': DEFAULT_CONFIG['codec']
136
+ },
137
+ {
138
+ 'name': 'framerate',
139
+ 'label': 'Frame Rate (FPS)',
140
+ 'type': 'number',
141
+ 'default': DEFAULT_CONFIG['framerate'],
142
+ 'min': 1,
143
+ 'max': 120,
144
+ 'step': 0.1
145
+ },
146
+ {
147
+ 'name': 'auto_resolution',
148
+ 'label': 'Auto Resolution',
149
+ 'type': 'checkbox',
150
+ 'default': DEFAULT_CONFIG['auto_resolution']
151
+ },
152
+ {
153
+ 'name': 'width',
154
+ 'label': 'Width',
155
+ 'type': 'number',
156
+ 'default': DEFAULT_CONFIG['width'],
157
+ 'min': 1,
158
+ 'max': 7680
159
+ },
160
+ {
161
+ 'name': 'height',
162
+ 'label': 'Height',
163
+ 'type': 'number',
164
+ 'default': DEFAULT_CONFIG['height'],
165
+ 'min': 1,
166
+ 'max': 4320
167
+ },
168
+ {
169
+ 'name': 'resize_method',
170
+ 'label': 'Resize Method',
171
+ 'type': 'select',
172
+ 'options': [
173
+ {'value': 'fit', 'label': 'Fit (preserve aspect, letterbox)'},
174
+ {'value': 'fill', 'label': 'Fill (preserve aspect, crop)'},
175
+ {'value': 'stretch', 'label': 'Stretch (ignore aspect)'}
176
+ ],
177
+ 'default': DEFAULT_CONFIG['resize_method']
178
+ },
179
+ {
180
+ 'name': 'clip_length',
181
+ 'label': 'Clip Length (frames)',
182
+ 'type': 'number',
183
+ 'default': DEFAULT_CONFIG['clip_length'],
184
+ 'min': 0,
185
+ 'placeholder': '0 = unlimited'
186
+ },
187
+ ]
188
+
189
+ def __init__(self, node_id=None, name="video_writer"):
190
+ super().__init__(node_id, name)
191
+ self._writer = None
192
+ self._current_file = None
193
+ self._frame_count = 0
194
+ self._file_counter = 0
195
+ self._recording = False
196
+ self._actual_width = None
197
+ self._actual_height = None
198
+
199
+ def _generate_filename(self):
200
+ """Generate filename based on naming mode"""
201
+ naming_mode = self.config.get('naming_mode', 'counter')
202
+ filename = self.config.get('filename', 'video_{counter}')
203
+ counter_digits = self.config.get('counter_digits', 4)
204
+
205
+ if naming_mode == 'counter':
206
+ self._file_counter += 1
207
+ counter_str = str(self._file_counter).zfill(counter_digits)
208
+ filename = filename.replace('{counter}', counter_str)
209
+ elif naming_mode == 'timestamp':
210
+ timestamp = str(int(time.time()))
211
+ filename = filename.replace('{timestamp}', timestamp)
212
+ filename = filename.replace('{counter}', timestamp)
213
+ elif naming_mode == 'datetime':
214
+ dt_str = datetime.now().strftime('%Y%m%d_%H%M%S')
215
+ filename = filename.replace('{datetime}', dt_str)
216
+ filename = filename.replace('{counter}', dt_str)
217
+ # 'message' mode is handled in process() with msg.filename
218
+
219
+ return filename
220
+
221
+ def _get_file_extension(self):
222
+ """Get the appropriate file extension for the selected codec"""
223
+ codec = self.config.get('codec', 'mp4v')
224
+ return self.CODECS.get(codec, self.CODECS['mp4v'])['ext']
225
+
226
+ def _resize_frame(self, frame, target_width, target_height):
227
+ """Resize frame according to the configured resize method"""
228
+ h, w = frame.shape[:2]
229
+ resize_method = self.config.get('resize_method', 'fit')
230
+
231
+ if w == target_width and h == target_height:
232
+ return frame
233
+
234
+ if resize_method == 'stretch':
235
+ return cv2.resize(frame, (target_width, target_height))
236
+
237
+ elif resize_method == 'fit':
238
+ # Calculate scale to fit within target while preserving aspect ratio
239
+ scale = min(target_width / w, target_height / h)
240
+ new_w = int(w * scale)
241
+ new_h = int(h * scale)
242
+
243
+ resized = cv2.resize(frame, (new_w, new_h))
244
+
245
+ # Create canvas and center the resized image (letterbox)
246
+ canvas = np.zeros((target_height, target_width, 3), dtype=np.uint8)
247
+ x_offset = (target_width - new_w) // 2
248
+ y_offset = (target_height - new_h) // 2
249
+ canvas[y_offset:y_offset+new_h, x_offset:x_offset+new_w] = resized
250
+
251
+ return canvas
252
+
253
+ elif resize_method == 'fill':
254
+ # Calculate scale to fill target while preserving aspect ratio (will crop)
255
+ scale = max(target_width / w, target_height / h)
256
+ new_w = int(w * scale)
257
+ new_h = int(h * scale)
258
+
259
+ resized = cv2.resize(frame, (new_w, new_h))
260
+
261
+ # Crop center to target size
262
+ x_offset = (new_w - target_width) // 2
263
+ y_offset = (new_h - target_height) // 2
264
+
265
+ return resized[y_offset:y_offset+target_height, x_offset:x_offset+target_width]
266
+
267
+ return frame
268
+
269
+ def _start_recording(self, frame, msg=None):
270
+ """Start a new video recording"""
271
+ # Determine output resolution
272
+ auto_resolution = self.config.get('auto_resolution', False)
273
+
274
+ if auto_resolution:
275
+ self._actual_height, self._actual_width = frame.shape[:2]
276
+ else:
277
+ self._actual_width = self.config.get('width', 1920)
278
+ self._actual_height = self.config.get('height', 1080)
279
+
280
+ # Generate filename
281
+ naming_mode = self.config.get('naming_mode', 'counter')
282
+ if naming_mode == 'message' and msg and 'filename' in msg:
283
+ filename = msg['filename']
284
+ else:
285
+ filename = self._generate_filename()
286
+
287
+ # Ensure output directory exists
288
+ output_path = self.config.get('path', './output')
289
+ try:
290
+ os.makedirs(output_path, exist_ok=True)
291
+ except Exception as e:
292
+ self.report_error(f"Failed to create output directory {output_path}: {e}")
293
+ return None
294
+
295
+ # Build full file path
296
+ ext = self._get_file_extension()
297
+ if not filename.endswith(ext):
298
+ filename = filename + ext
299
+ self._current_file = os.path.abspath(os.path.join(output_path, filename))
300
+
301
+ # Get codec fourcc
302
+ codec = self.config.get('codec', 'mp4v')
303
+ fourcc_str = self.CODECS.get(codec, self.CODECS['mp4v'])['fourcc']
304
+ fourcc = cv2.VideoWriter_fourcc(*fourcc_str) # type: ignore[attr-defined]
305
+
306
+ # Get framerate
307
+ framerate = float(self.config.get('framerate', 30.0))
308
+
309
+ # print(f"[VideoWriter] Creating: {self._current_file}, {fourcc_str}, {framerate}fps, {self._actual_width}x{self._actual_height}")
310
+
311
+ # Create VideoWriter
312
+ self._writer = cv2.VideoWriter(
313
+ self._current_file,
314
+ fourcc,
315
+ framerate,
316
+ (self._actual_width, self._actual_height)
317
+ )
318
+
319
+ if not self._writer.isOpened():
320
+ self.report_error(f"Failed to create video writer for {self._current_file}")
321
+ self._writer = None
322
+ return None
323
+
324
+ self._frame_count = 0
325
+ self._recording = True
326
+
327
+ # print(f"[VideoWriter] Started recording: {self._current_file} ({self._actual_width}x{self._actual_height})")
328
+
329
+ # Return recording start event message
330
+ return {
331
+ 'event': 'recording_start',
332
+ 'filename': self._current_file,
333
+ 'width': self._actual_width,
334
+ 'height': self._actual_height,
335
+ 'framerate': framerate,
336
+ 'codec': codec
337
+ }
338
+
339
+ def _stop_recording(self):
340
+ """Stop current recording and return event message"""
341
+ if self._writer is not None:
342
+ self._writer.release()
343
+ self._writer = None
344
+
345
+ if self._recording:
346
+ self._recording = False
347
+ filename = self._current_file
348
+ frames = self._frame_count
349
+ self._current_file = None
350
+ self._frame_count = 0
351
+
352
+ # print(f"[VideoWriter] Stopped recording: {filename} ({frames} frames)")
353
+
354
+ return {
355
+ 'event': 'recording_end',
356
+ 'filename': filename,
357
+ 'frames': frames
358
+ }
359
+ return None
360
+
361
+ def on_input(self, msg: dict, input_index: int = 0):
362
+ """Process incoming message with image frame - does NOT forward messages"""
363
+ # Get image from payload
364
+ payload = msg.get(MessageKeys.PAYLOAD, {})
365
+ if isinstance(payload, dict):
366
+ image_data = payload.get(MessageKeys.IMAGE.PATH)
367
+ else:
368
+ image_data = payload if isinstance(payload, np.ndarray) else None
369
+
370
+ if image_data is None:
371
+ return
372
+
373
+ # Decode image using base node helper (handles dict format, base64, numpy, etc.)
374
+ image, format_type = self.decode_image({MessageKeys.IMAGE.PATH: image_data})
375
+
376
+ if image is None:
377
+ self.report_error("Failed to decode image from payload")
378
+ return
379
+
380
+ # Ensure image is in correct format (BGR for OpenCV VideoWriter)
381
+ if len(image.shape) == 2:
382
+ # Grayscale to BGR
383
+ image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
384
+ elif len(image.shape) == 3 and image.shape[2] == 4:
385
+ # RGBA to BGR
386
+ image = cv2.cvtColor(image, cv2.COLOR_RGBA2BGR)
387
+ elif len(image.shape) == 3 and image.shape[2] == 3:
388
+ # Already BGR or RGB - assume BGR (OpenCV standard)
389
+ pass
390
+ else:
391
+ self.report_error(f"Unexpected image shape: {image.shape}")
392
+ return
393
+
394
+ event_msg = None
395
+
396
+ # Check if we need to start a new recording
397
+ clip_length = self.config.get('clip_length', 0)
398
+
399
+ if not self._recording:
400
+ event_msg = self._start_recording(image, msg)
401
+ if event_msg:
402
+ # Send start event (status only, no image)
403
+ self.send({
404
+ MessageKeys.MSG_ID: msg.get(MessageKeys.MSG_ID),
405
+ MessageKeys.PAYLOAD: event_msg
406
+ })
407
+
408
+ # Check clip length limit
409
+ if clip_length > 0 and self._frame_count >= clip_length:
410
+ end_event = self._stop_recording()
411
+ if end_event:
412
+ # Send end event (status only, no image)
413
+ self.send({
414
+ MessageKeys.MSG_ID: msg.get(MessageKeys.MSG_ID),
415
+ MessageKeys.PAYLOAD: end_event
416
+ })
417
+ # Start new recording
418
+ event_msg = self._start_recording(image, msg)
419
+ if event_msg:
420
+ self.send({
421
+ MessageKeys.MSG_ID: msg.get(MessageKeys.MSG_ID),
422
+ MessageKeys.PAYLOAD: event_msg
423
+ })
424
+
425
+ # Write frame
426
+ if self._writer is not None:
427
+ # Always resize to match the video's resolution
428
+ # (set from first frame if auto_resolution, or from config otherwise)
429
+ image = self._resize_frame(image, self._actual_width, self._actual_height)
430
+
431
+ self._writer.write(image)
432
+ self._frame_count += 1
433
+ # Note: No return/send of input message - only recording events are sent
434
+
435
+ def close(self):
436
+ """Clean up when node is removed or flow stops"""
437
+ event = self._stop_recording()
438
+ if event:
439
+ # Try to send final event
440
+ self.send({MessageKeys.PAYLOAD: event})
441
+ # super().close()
@@ -0,0 +1,3 @@
1
+ from .webhook_node import WebhookNode
2
+
3
+ __all__ = ['WebhookNode']