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