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,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()
|