nedo-vision-worker 1.1.2__py3-none-any.whl → 1.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 (43) hide show
  1. nedo_vision_worker/__init__.py +1 -1
  2. nedo_vision_worker/cli.py +197 -168
  3. nedo_vision_worker/database/DatabaseManager.py +3 -3
  4. nedo_vision_worker/doctor.py +1066 -386
  5. nedo_vision_worker/models/ai_model.py +35 -2
  6. nedo_vision_worker/protos/AIModelService_pb2.py +12 -10
  7. nedo_vision_worker/protos/AIModelService_pb2_grpc.py +1 -1
  8. nedo_vision_worker/protos/DatasetSourceService_pb2.py +2 -2
  9. nedo_vision_worker/protos/DatasetSourceService_pb2_grpc.py +1 -1
  10. nedo_vision_worker/protos/HumanDetectionService_pb2.py +2 -2
  11. nedo_vision_worker/protos/HumanDetectionService_pb2_grpc.py +1 -1
  12. nedo_vision_worker/protos/PPEDetectionService_pb2.py +2 -2
  13. nedo_vision_worker/protos/PPEDetectionService_pb2_grpc.py +1 -1
  14. nedo_vision_worker/protos/VisionWorkerService_pb2.py +2 -2
  15. nedo_vision_worker/protos/VisionWorkerService_pb2_grpc.py +1 -1
  16. nedo_vision_worker/protos/WorkerSourcePipelineService_pb2.py +2 -2
  17. nedo_vision_worker/protos/WorkerSourcePipelineService_pb2_grpc.py +1 -1
  18. nedo_vision_worker/protos/WorkerSourceService_pb2.py +2 -2
  19. nedo_vision_worker/protos/WorkerSourceService_pb2_grpc.py +1 -1
  20. nedo_vision_worker/services/AIModelClient.py +184 -160
  21. nedo_vision_worker/services/DirectDeviceToRTMPStreamer.py +534 -0
  22. nedo_vision_worker/services/GrpcClientBase.py +142 -108
  23. nedo_vision_worker/services/PPEDetectionClient.py +0 -7
  24. nedo_vision_worker/services/RestrictedAreaClient.py +0 -5
  25. nedo_vision_worker/services/SharedDirectDeviceClient.py +278 -0
  26. nedo_vision_worker/services/SharedVideoStreamServer.py +315 -0
  27. nedo_vision_worker/services/SystemWideDeviceCoordinator.py +236 -0
  28. nedo_vision_worker/services/VideoSharingDaemon.py +832 -0
  29. nedo_vision_worker/services/VideoStreamClient.py +30 -13
  30. nedo_vision_worker/services/WorkerSourceClient.py +1 -1
  31. nedo_vision_worker/services/WorkerSourcePipelineClient.py +28 -6
  32. nedo_vision_worker/services/WorkerSourceUpdater.py +30 -3
  33. nedo_vision_worker/util/VideoProbeUtil.py +222 -15
  34. nedo_vision_worker/worker/DataSyncWorker.py +1 -0
  35. nedo_vision_worker/worker/PipelineImageWorker.py +1 -1
  36. nedo_vision_worker/worker/VideoStreamWorker.py +27 -3
  37. nedo_vision_worker/worker/WorkerManager.py +2 -29
  38. nedo_vision_worker/worker_service.py +24 -11
  39. {nedo_vision_worker-1.1.2.dist-info → nedo_vision_worker-1.2.0.dist-info}/METADATA +1 -3
  40. {nedo_vision_worker-1.1.2.dist-info → nedo_vision_worker-1.2.0.dist-info}/RECORD +43 -38
  41. {nedo_vision_worker-1.1.2.dist-info → nedo_vision_worker-1.2.0.dist-info}/WHEEL +0 -0
  42. {nedo_vision_worker-1.1.2.dist-info → nedo_vision_worker-1.2.0.dist-info}/entry_points.txt +0 -0
  43. {nedo_vision_worker-1.1.2.dist-info → nedo_vision_worker-1.2.0.dist-info}/top_level.txt +0 -0
@@ -7,6 +7,7 @@ import ffmpeg
7
7
  import fractions
8
8
  from urllib.parse import urlparse
9
9
  from .GrpcClientBase import GrpcClientBase
10
+ from .SharedDirectDeviceClient import SharedDirectDeviceClient
10
11
  from ..protos.VisionWorkerService_pb2_grpc import VideoStreamServiceStub
11
12
  from ..protos.VisionWorkerService_pb2 import VideoFrame
12
13
 
@@ -15,9 +16,12 @@ class VideoStreamClient(GrpcClientBase):
15
16
  def __init__(self, server_host: str, server_port: int = 50051):
16
17
  """Initialize the video stream client."""
17
18
  super().__init__(server_host, server_port)
19
+ self.shared_device_client = SharedDirectDeviceClient()
18
20
 
19
21
  def _detect_stream_type(self, url):
20
- """Detect whether the stream is RTSP or HLS based on the URL scheme."""
22
+ if isinstance(url, str) and url.isdigit():
23
+ return "direct"
24
+
21
25
  parsed_url = urlparse(url)
22
26
  if parsed_url.scheme == "rtsp":
23
27
  return "rtsp"
@@ -27,8 +31,12 @@ class VideoStreamClient(GrpcClientBase):
27
31
  return "unknown"
28
32
 
29
33
  def _get_video_properties(self, url, stream_type):
30
- """Extract FPS, resolution, and pixel format dynamically for RTSP or HLS."""
31
34
  try:
35
+ if stream_type == "direct":
36
+ # Use the shared device client for direct devices
37
+ width, height, fps, pixel_format = self.shared_device_client.get_video_properties(url)
38
+ return width, height, fps, pixel_format
39
+
32
40
  probe_cmd = [
33
41
  "ffprobe",
34
42
  "-i", url,
@@ -59,12 +67,10 @@ class VideoStreamClient(GrpcClientBase):
59
67
  return None, None, None, None
60
68
 
61
69
  def _get_bytes_per_pixel(self, pixel_format):
62
- """Determine bytes per pixel based on pixel format."""
63
70
  pixel_map = {"rgb24": 3, "yuv420p": 1.5, "gray": 1}
64
- return pixel_map.get(pixel_format, 3) # Default to 3 bytes (RGB)
71
+ return pixel_map.get(pixel_format, 3)
65
72
 
66
73
  def _generate_frames(self, url, worker_id, uuid, stream_duration):
67
- """Generator function to continuously stream video frames to gRPC."""
68
74
  stream_type = self._detect_stream_type(url)
69
75
  if stream_type == "unknown":
70
76
  logging.error(f"Unsupported stream type: {url}")
@@ -77,13 +83,20 @@ class VideoStreamClient(GrpcClientBase):
77
83
 
78
84
  bytes_per_pixel = self._get_bytes_per_pixel(pixel_format)
79
85
  frame_size = int(width * height * bytes_per_pixel)
80
- frame_interval = 1.0 / fps # Time between frames
86
+ frame_interval = 1.0 / fps
81
87
  start_time = time.time()
82
88
  empty_frame_count = 0
83
89
 
84
90
  logging.info(f"Streaming {stream_type.upper()} from: {url} for {stream_duration} seconds...")
85
91
 
86
- if stream_type == "rtsp":
92
+ if stream_type == "direct":
93
+ # Use the shared device client for direct devices
94
+ try:
95
+ ffmpeg_input = self.shared_device_client.create_ffmpeg_input(url, width, height, fps)
96
+ except Exception as e:
97
+ logging.error(f"Failed to create ffmpeg input for direct device: {e}")
98
+ return
99
+ elif stream_type == "rtsp":
87
100
  ffmpeg_input = (
88
101
  ffmpeg
89
102
  .input(url, rtsp_transport="tcp", fflags="nobuffer", timeout="5000000")
@@ -112,12 +125,12 @@ class VideoStreamClient(GrpcClientBase):
112
125
  empty_frame_count += 1
113
126
  logging.warning(f"Empty frame received ({empty_frame_count}), retrying...")
114
127
 
115
- if empty_frame_count > 5: # Stop if 5 consecutive empty frames
128
+ if empty_frame_count > 5:
116
129
  logging.error("Too many empty frames, stopping stream...")
117
130
  break
118
- continue # Try reading the next frame
131
+ continue
119
132
 
120
- empty_frame_count = 0 # Reset empty frame count
133
+ empty_frame_count = 0
121
134
  yield VideoFrame(
122
135
  worker_id=worker_id,
123
136
  uuid=uuid,
@@ -125,16 +138,20 @@ class VideoStreamClient(GrpcClientBase):
125
138
  timestamp=int(time.time() * 1000),
126
139
  )
127
140
 
128
- time.sleep(frame_interval) # Ensure proper frame timing
141
+ time.sleep(frame_interval)
129
142
 
130
143
  except Exception as e:
131
144
  logging.error(f"Streaming error: {e}")
132
145
 
133
146
  finally:
147
+ # Release device access for direct devices
148
+ if stream_type == "direct":
149
+ self.shared_device_client.release_device_access(url)
150
+
134
151
  stderr_output = process.stderr.read().decode()
135
- logging.error(f"FFmpeg stderr: {stderr_output}") # Log errors from FFmpeg
152
+ logging.error(f"FFmpeg stderr: {stderr_output}")
136
153
  process.terminate()
137
- process.wait() # Ensures cleanup
154
+ process.wait()
138
155
 
139
156
  def stream_video(self, worker_id, uuid, url, stream_duration):
140
157
  """
@@ -87,7 +87,7 @@ class WorkerSourceClient(GrpcClientBase):
87
87
  if existing_source.worker_id != source.worker_id:
88
88
  changes.append(f"worker_id: {existing_source.worker_id} → {source.worker_id}")
89
89
  if existing_source.type_code != source.type_code:
90
- if source.type_code == "live":
90
+ if source.type_code in ["live", "direct"]:
91
91
  self.delete_local_source_file(existing_source.file_path)
92
92
  elif existing_source.file_path == source.file_path:
93
93
  self.download_source_file(source)
@@ -9,6 +9,7 @@ from ..database.DatabaseManager import _get_storage_paths
9
9
  from ..repositories.WorkerSourcePipelineDebugRepository import WorkerSourcePipelineDebugRepository
10
10
  from ..repositories.WorkerSourcePipelineDetectionRepository import WorkerSourcePipelineDetectionRepository
11
11
  from .GrpcClientBase import GrpcClientBase
12
+ from .SharedDirectDeviceClient import SharedDirectDeviceClient
12
13
  from ..protos.WorkerSourcePipelineService_pb2_grpc import WorkerSourcePipelineServiceStub
13
14
  from ..protos.WorkerSourcePipelineService_pb2 import GetListByWorkerIdRequest, SendPipelineImageRequest, UpdatePipelineStatusRequest, SendPipelineDebugRequest, SendPipelineDetectionDataRequest
14
15
  from ..repositories.WorkerSourcePipelineRepository import WorkerSourcePipelineRepository
@@ -22,6 +23,7 @@ class WorkerSourcePipelineClient(GrpcClientBase):
22
23
  self.detection_repo = WorkerSourcePipelineDetectionRepository()
23
24
  storage_paths = _get_storage_paths()
24
25
  self.source_file_path = storage_paths["files"] / "source_files"
26
+ self.shared_device_client = SharedDirectDeviceClient()
25
27
 
26
28
  # Track video playback positions and last fetch times
27
29
  self.video_positions = {} # {video_path: current_position_in_seconds}
@@ -34,7 +36,9 @@ class WorkerSourcePipelineClient(GrpcClientBase):
34
36
  self.stub = None
35
37
 
36
38
  def _detect_stream_type(self, url):
37
- """Detect whether the stream is RTSP, HLS, or video file based on the URL scheme."""
39
+ if isinstance(url, str) and url.isdigit():
40
+ return "direct"
41
+
38
42
  parsed_url = urlparse(url)
39
43
  if parsed_url.scheme == "rtsp":
40
44
  return "rtsp"
@@ -169,10 +173,27 @@ class WorkerSourcePipelineClient(GrpcClientBase):
169
173
  return status
170
174
 
171
175
  def _get_single_frame_bytes(self, url):
172
- """Get a single frame from RTSP, HLS, or video file as JPEG bytes."""
173
176
  stream_type = self._detect_stream_type(url)
174
177
 
175
- if stream_type == "rtsp":
178
+ if stream_type == "direct":
179
+ device_index = int(url)
180
+ logging.info(f"📹 [APP] Capturing frame from direct video device: {device_index}")
181
+
182
+ # Use the shared device client for direct devices
183
+ try:
184
+ # Get device properties first
185
+ width, height, fps, pixel_format = self.shared_device_client.get_video_properties(url)
186
+ if not width or not height:
187
+ logging.error(f"Failed to get properties for device {device_index}")
188
+ return None
189
+
190
+ # Create ffmpeg input using shared device client
191
+ ffmpeg_input = self.shared_device_client.create_ffmpeg_input(url, width, height, fps)
192
+
193
+ except Exception as e:
194
+ logging.error(f"Error setting up direct device {device_index}: {e}")
195
+ return None
196
+ elif stream_type == "rtsp":
176
197
  ffmpeg_input = (
177
198
  ffmpeg
178
199
  .input(url, rtsp_transport="tcp", fflags="nobuffer", timeout="5000000")
@@ -186,13 +207,11 @@ class WorkerSourcePipelineClient(GrpcClientBase):
186
207
  file_path = self.source_file_path / os.path.basename(url)
187
208
  file_path_str = str(file_path)
188
209
 
189
- # Check if file exists
190
210
  if not os.path.exists(file_path_str):
191
211
  logging.error(f"Video file does not exist: {file_path_str}")
192
212
  return None
193
213
 
194
214
  current_position = self._get_current_video_position(file_path_str)
195
-
196
215
  logging.info(f"🎬 [APP] Capturing video frame at position {current_position:.2f}s from {file_path_str}")
197
216
 
198
217
  ffmpeg_input = (
@@ -201,7 +220,6 @@ class WorkerSourcePipelineClient(GrpcClientBase):
201
220
  )
202
221
  elif stream_type == "image_file":
203
222
  file_path = self.source_file_path / os.path.basename(url)
204
-
205
223
  logging.info(f"🖼️ [APP] Capturing image frame from {file_path}")
206
224
 
207
225
  ffmpeg_input = (
@@ -246,6 +264,10 @@ class WorkerSourcePipelineClient(GrpcClientBase):
246
264
  return None
247
265
 
248
266
  finally:
267
+ # Release device access for direct devices
268
+ if stream_type == "direct":
269
+ self.shared_device_client.release_device_access(url)
270
+
249
271
  process.terminate()
250
272
  process.wait()
251
273
 
@@ -27,8 +27,13 @@ class WorkerSourceUpdater:
27
27
  self._lock = threading.RLock()
28
28
 
29
29
  def _get_source_metadata(self, source):
30
- """Get metadata for a worker source."""
31
- url = source.url if source.type_code == "live" else source.file_path
30
+ if source.type_code == "live":
31
+ url = source.url
32
+ elif source.type_code == "direct":
33
+ url = source.url
34
+ else:
35
+ url = source.file_path
36
+
32
37
  if not url:
33
38
  return None
34
39
 
@@ -50,7 +55,29 @@ class WorkerSourceUpdater:
50
55
  for source in worker_sources:
51
56
  metadata = self._get_source_metadata(source)
52
57
  if not metadata:
53
- logger.warning(f"⚠️ [APP] Failed to probe video for Worker Source ID {source.id}")
58
+ logger.warning(f"⚠️ [APP] Failed to probe video for Worker Source ID {source.id} (type: {source.type_code}, url: {source.url if hasattr(source, 'url') else 'N/A'})")
59
+ # Set disconnected status for failed probes
60
+ if source.status_code != "disconnected":
61
+ source.status_code = "disconnected"
62
+ source.resolution = None
63
+ source.frame_rate = None
64
+ updated_records.append(source)
65
+
66
+ # Send gRPC update for disconnected status
67
+ worker_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
68
+ response = self.client.update_worker_source(
69
+ worker_source_id=source.id,
70
+ resolution=None,
71
+ status_code="disconnected",
72
+ frame_rate=None,
73
+ worker_timestamp=worker_timestamp,
74
+ token=self.token,
75
+ )
76
+
77
+ if response.get("success"):
78
+ logger.info(f"✅ [APP] Updated Worker Source ID {source.id} to disconnected")
79
+ else:
80
+ logger.error(f"🚨 [APP] Failed to update Worker Source ID {source.id} to disconnected: {response.get('message')}")
54
81
  continue
55
82
 
56
83
  # Extract details
@@ -4,16 +4,35 @@ import logging
4
4
  import json
5
5
  import fractions
6
6
  import shutil
7
+ import sys
8
+ import platform
9
+ from pathlib import Path
7
10
  from urllib.parse import urlparse
8
11
 
12
+
13
+ try:
14
+ from nedo_vision_worker.services.VideoSharingDaemon import VideoSharingClient
15
+ except ImportError:
16
+ logging.warning("VideoSharingDaemon not available")
17
+ VideoSharingClient = None
18
+
19
+ # Import DatabaseManager for storage path
20
+ try:
21
+ from nedo_vision_worker.database.DatabaseManager import get_storage_path
22
+ except ImportError:
23
+ get_storage_path = None
24
+
9
25
  class VideoProbeUtil:
10
26
  """Utility to extract metadata from video URLs using OpenCV and ffmpeg."""
11
27
 
12
28
  @staticmethod
13
29
  def get_video_metadata(video_url: str) -> dict:
14
- """Extracts resolution and frame rate from a video URL using OpenCV or ffmpeg."""
15
30
  try:
16
- # metadata = VideoProbeUtil._get_metadata_opencv(video_url)
31
+ if isinstance(video_url, str) and video_url.isdigit():
32
+ metadata = VideoProbeUtil._get_metadata_direct_device(video_url)
33
+ if metadata:
34
+ return metadata
35
+
17
36
  metadata = VideoProbeUtil._get_metadata_ffmpeg(video_url)
18
37
  return metadata
19
38
 
@@ -45,13 +64,208 @@ class VideoProbeUtil:
45
64
  "timestamp": None
46
65
  }
47
66
 
67
+ @staticmethod
68
+ def _get_metadata_opencv_direct_device(device_idx: int) -> dict:
69
+ """Fallback method to get metadata directly from camera device using OpenCV."""
70
+ try:
71
+ logging.debug(f"Attempting direct OpenCV access to device {device_idx}")
72
+ cap = cv2.VideoCapture(device_idx)
73
+
74
+ if not cap.isOpened():
75
+ logging.warning(f"⚠️ [APP] OpenCV failed to open device {device_idx}")
76
+ return None
77
+
78
+ # Set a reasonable timeout and try to read a frame
79
+ cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Reduce buffer to get fresh frames
80
+
81
+ # Try multiple attempts to read a frame (some cameras need warm-up)
82
+ ret, frame = False, None
83
+ for attempt in range(3):
84
+ ret, frame = cap.read()
85
+ if ret and frame is not None:
86
+ break
87
+ logging.debug(f"Attempt {attempt + 1} failed to read frame from device {device_idx}")
88
+
89
+ if not ret or frame is None:
90
+ logging.warning(f"⚠️ [APP] OpenCV failed to read frame from device {device_idx} after 3 attempts")
91
+ cap.release()
92
+ return None
93
+
94
+ # Get video properties
95
+ height, width = frame.shape[:2]
96
+ frame_rate = cap.get(cv2.CAP_PROP_FPS)
97
+
98
+ # Some cameras report 0 FPS, use a reasonable default
99
+ if frame_rate <= 0:
100
+ frame_rate = 30.0
101
+ else:
102
+ frame_rate = round(frame_rate, 2)
103
+
104
+ cap.release()
105
+
106
+ logging.info(f"✅ [APP] Successfully probed device {device_idx} directly: {width}x{height} @ {frame_rate}fps")
107
+
108
+ return {
109
+ "resolution": f"{width}x{height}",
110
+ "frame_rate": frame_rate,
111
+ "timestamp": None
112
+ }
113
+
114
+ except Exception as e:
115
+ logging.error(f"❌ [APP] Error probing device {device_idx} with OpenCV: {e}")
116
+ return None
117
+
118
+ @staticmethod
119
+ def _get_metadata_ffmpeg_direct_device(device_idx: int) -> dict:
120
+ """Fallback method to get metadata from camera device using FFmpeg."""
121
+ if not shutil.which("ffprobe"):
122
+ logging.warning("⚠️ [APP] ffprobe not available for device probing")
123
+ return None
124
+
125
+ try:
126
+ system = platform.system().lower()
127
+
128
+ # Determine the device input format based on OS
129
+ if system == "linux":
130
+ device_path = f"/dev/video{device_idx}"
131
+ input_format = "v4l2"
132
+ cmd = ["ffprobe", "-f", input_format, "-i", device_path]
133
+ elif system == "windows":
134
+ # Windows DirectShow device
135
+ input_format = "dshow"
136
+ device_name = f"video={device_idx}" # This might need adjustment based on actual device names
137
+ cmd = ["ffprobe", "-f", input_format, "-i", device_name]
138
+ elif system == "darwin": # macOS
139
+ input_format = "avfoundation"
140
+ cmd = ["ffprobe", "-f", input_format, "-i", str(device_idx)]
141
+ else:
142
+ logging.warning(f"⚠️ [APP] Unsupported platform for FFmpeg device access: {system}")
143
+ return None
144
+
145
+ # Add common ffprobe arguments
146
+ cmd.extend([
147
+ "-v", "error",
148
+ "-select_streams", "v:0",
149
+ "-show_entries", "stream=width,height,avg_frame_rate",
150
+ "-of", "json"
151
+ ])
152
+
153
+ logging.debug(f"Running FFmpeg command: {' '.join(cmd)}")
154
+
155
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
156
+
157
+ if result.returncode != 0:
158
+ logging.warning(f"⚠️ [APP] FFmpeg failed for device {device_idx}: {result.stderr.strip()}")
159
+ return None
160
+
161
+ if not result.stdout.strip():
162
+ logging.warning(f"⚠️ [APP] No output from FFmpeg for device {device_idx}")
163
+ return None
164
+
165
+ metadata = json.loads(result.stdout)
166
+ streams = metadata.get("streams", [])
167
+
168
+ if not streams:
169
+ logging.warning(f"⚠️ [APP] No video streams found for device {device_idx}")
170
+ return None
171
+
172
+ stream = streams[0]
173
+ width = stream.get("width")
174
+ height = stream.get("height")
175
+ avg_fps = stream.get("avg_frame_rate", "30/1")
176
+
177
+ try:
178
+ frame_rate = round(float(fractions.Fraction(avg_fps)), 2)
179
+ except (ValueError, ZeroDivisionError):
180
+ frame_rate = 30.0
181
+
182
+ if not width or not height:
183
+ logging.warning(f"⚠️ [APP] Invalid resolution from FFmpeg for device {device_idx}")
184
+ return None
185
+
186
+ logging.info(f"✅ [APP] Successfully probed device {device_idx} with FFmpeg: {width}x{height} @ {frame_rate}fps")
187
+
188
+ return {
189
+ "resolution": f"{width}x{height}",
190
+ "frame_rate": frame_rate,
191
+ "timestamp": None
192
+ }
193
+
194
+ except subprocess.TimeoutExpired:
195
+ logging.warning(f"⚠️ [APP] FFmpeg timeout for device {device_idx}")
196
+ except json.JSONDecodeError:
197
+ logging.error(f"❌ [APP] Failed to parse FFmpeg output for device {device_idx}")
198
+ except Exception as e:
199
+ logging.error(f"❌ [APP] Error probing device {device_idx} with FFmpeg: {e}")
200
+
201
+ return None
202
+
203
+ @staticmethod
204
+ def _get_metadata_direct_device(device_index: str) -> dict:
205
+ try:
206
+ device_idx = int(device_index)
207
+
208
+ logging.debug(f"VideoSharingClient available: {VideoSharingClient is not None}")
209
+
210
+ # Try to use VideoSharingClient first for cross-process access
211
+ if VideoSharingClient:
212
+ try:
213
+ logging.debug("Attempting to use VideoSharingClient...")
214
+
215
+ # Get storage path from DatabaseManager
216
+ storage_path = None
217
+ if get_storage_path:
218
+ try:
219
+ storage_path = str(get_storage_path())
220
+ logging.debug(f"Got storage path: {storage_path}")
221
+ except Exception as e:
222
+ logging.debug(f"Could not get storage path: {e}")
223
+
224
+ # Create temporary client to get device properties
225
+ video_client = VideoSharingClient(device_idx, storage_path=storage_path)
226
+ logging.debug(f"Created VideoSharingClient, info_file: {video_client.info_file}")
227
+
228
+ # Load daemon info to get properties without connecting
229
+ if video_client._load_daemon_info():
230
+ width = video_client.width
231
+ height = video_client.height
232
+ fps = video_client.fps
233
+
234
+ return {
235
+ "resolution": f"{width}x{height}",
236
+ "frame_rate": round(fps, 2) if fps > 0 else 30.0,
237
+ "timestamp": None
238
+ }
239
+ else:
240
+ logging.debug(f"Video sharing daemon not available for device {device_idx}")
241
+
242
+ except Exception as e:
243
+ logging.debug(f"Video sharing not available for device {device_idx}: {e}")
244
+ import traceback
245
+ logging.debug(traceback.format_exc())
246
+
247
+ # Fallback 1: Try direct OpenCV access
248
+ logging.info(f"🔄 [APP] Daemon not available for device {device_idx}, falling back to direct OpenCV access")
249
+ metadata = VideoProbeUtil._get_metadata_opencv_direct_device(device_idx)
250
+ if metadata:
251
+ return metadata
252
+
253
+ # Fallback 2: Try FFmpeg for device access (Linux v4l2, Windows dshow)
254
+ logging.info(f"🔄 [APP] OpenCV failed for device {device_idx}, trying FFmpeg device access")
255
+ return VideoProbeUtil._get_metadata_ffmpeg_direct_device(device_idx)
256
+
257
+ except Exception as e:
258
+ logging.error(f"❌ [APP] Error getting metadata from direct device {device_index}: {e}")
259
+ return None
260
+
48
261
  @staticmethod
49
262
  def _detect_stream_type(video_url: str) -> str:
50
- """Detect whether the URL is RTSP, local file, or other type."""
51
- # Convert PosixPath to string if needed
52
263
  if hasattr(video_url, '__str__'):
53
264
  video_url = str(video_url)
54
265
 
266
+ if isinstance(video_url, str) and video_url.isdigit():
267
+ return "direct"
268
+
55
269
  parsed_url = urlparse(video_url)
56
270
  if parsed_url.scheme == "rtsp":
57
271
  return "rtsp"
@@ -62,45 +276,38 @@ class VideoProbeUtil:
62
276
 
63
277
  @staticmethod
64
278
  def _get_metadata_ffmpeg(video_url: str) -> dict:
65
- # Check if ffprobe is available
66
279
  if not shutil.which("ffprobe"):
67
280
  logging.error("⚠️ [APP] ffprobe is not installed or not found in PATH.")
68
281
  return None
69
282
 
70
- # Detect stream type
71
283
  stream_type = VideoProbeUtil._detect_stream_type(video_url)
72
284
 
73
- # Build ffprobe command based on stream type
285
+ if stream_type == "direct":
286
+ return VideoProbeUtil._get_metadata_direct_device(video_url)
287
+
74
288
  cmd = ["ffprobe", "-v", "error", "-select_streams", "v:0",
75
289
  "-show_entries", "stream=width,height,avg_frame_rate", "-of", "json"]
76
290
 
77
- # Add RTSP transport option only for RTSP streams
78
291
  if stream_type == "rtsp":
79
292
  cmd.insert(1, "-rtsp_transport")
80
293
  cmd.insert(2, "tcp")
81
294
 
82
- # Add the video URL
83
295
  cmd.append(video_url)
84
296
 
85
297
  try:
86
- # Run ffprobe command
87
298
  result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
88
299
 
89
- # Check for errors
90
300
  if result.returncode != 0 or not result.stdout.strip():
91
301
  logging.warning(f"⚠️ [APP] ffprobe failed for {video_url}: {result.stderr.strip()}")
92
302
  return None
93
303
 
94
- # Parse JSON output
95
304
  metadata = json.loads(result.stdout)
96
305
  streams = metadata.get("streams", [{}])[0]
97
306
 
98
- # Extract metadata
99
307
  width = streams.get("width")
100
308
  height = streams.get("height")
101
309
  avg_fps = streams.get("avg_frame_rate", "0/1")
102
310
 
103
- # Convert FPS safely
104
311
  try:
105
312
  frame_rate = round(float(fractions.Fraction(avg_fps)), 2)
106
313
  except (ValueError, ZeroDivisionError):
@@ -109,7 +316,7 @@ class VideoProbeUtil:
109
316
  return {
110
317
  "resolution": f"{width}x{height}" if width and height else None,
111
318
  "frame_rate": frame_rate,
112
- "timestamp": None # Placeholder if you need a timestamp later
319
+ "timestamp": None
113
320
  }
114
321
 
115
322
  except subprocess.TimeoutExpired:
@@ -64,6 +64,7 @@ class DataSyncWorker:
64
64
  """Stop the data sync worker."""
65
65
  logging.info("🛑 [DATA SYNC] Stopping DataSyncWorker.")
66
66
  self.running = False
67
+ self.ai_model_client.cleanup_downloads()
67
68
  safe_join_thread(self.thread)
68
69
  logging.info("🛑 [DATA SYNC] DataSyncWorker stopped.")
69
70
 
@@ -112,7 +112,7 @@ class PipelineImageWorker:
112
112
  response = self.worker_source_pipeline_client.send_pipeline_image(
113
113
  worker_source_pipeline_id=worker_source_pipeline_id,
114
114
  uuid=uuid,
115
- url=worker_source.url if worker_source.type_code == "live" else worker_source.file_path,
115
+ url=worker_source.url if worker_source.type_code in ["live", "direct"] else worker_source.file_path,
116
116
  token=self.token
117
117
  )
118
118
 
@@ -4,6 +4,8 @@ import logging
4
4
  import json
5
5
  from ..database.DatabaseManager import get_storage_path
6
6
  from ..services.FileToRTMPServer import FileToRTMPStreamer
7
+ from ..services.DirectDeviceToRTMPStreamer import DirectDeviceToRTMPStreamer
8
+ from ..services.SharedDirectDeviceClient import SharedDirectDeviceClient
7
9
  from .RabbitMQListener import RabbitMQListener
8
10
  from ..services.RTSPtoRTMPStreamer import RTSPtoRTMPStreamer
9
11
 
@@ -44,6 +46,9 @@ class VideoStreamWorker:
44
46
  self.thread = None
45
47
  self.stop_event = threading.Event()
46
48
  self.lock = threading.Lock()
49
+
50
+ # Initialize shared device client for direct video devices
51
+ self.shared_device_client = SharedDirectDeviceClient()
47
52
 
48
53
  # Initialize RabbitMQ listener
49
54
  self.listener = RabbitMQListener(
@@ -75,6 +80,11 @@ class VideoStreamWorker:
75
80
  safe_join_thread(self.thread) # Ensures the thread stops gracefully
76
81
  self.thread = None
77
82
  logger.info(f"🛑 [APP] Stream Worker stopped (Device: {self.worker_id}).")
83
+
84
+ def _is_direct_device(self, url) -> bool:
85
+ """Check if the URL is a direct video device."""
86
+ is_device, _ = self.shared_device_client._is_direct_device(url)
87
+ return is_device
78
88
 
79
89
  def _run(self):
80
90
  """Main loop to manage RabbitMQ listener."""
@@ -108,8 +118,19 @@ class VideoStreamWorker:
108
118
 
109
119
  logger.info(f"📡 [APP] Received video preview message ({data})")
110
120
 
111
- if not url or (not url.startswith("rtsp://") and not url.startswith("worker-source/")):
112
- logger.error(f"⚠️ [APP] Invalid URL: {url}")
121
+ # Validate URL - support RTSP, worker-source files, and direct devices
122
+ if not url:
123
+ logger.error(f"⚠️ [APP] Missing URL in message")
124
+ return
125
+
126
+ is_valid_url = (
127
+ url.startswith("rtsp://") or
128
+ url.startswith("worker-source/") or
129
+ self._is_direct_device(url)
130
+ )
131
+
132
+ if not is_valid_url:
133
+ logger.error(f"⚠️ [APP] Invalid URL: {url} (must be RTSP, worker-source file, or direct device)")
113
134
  return
114
135
 
115
136
  if stream_duration <= 0:
@@ -131,13 +152,16 @@ class VideoStreamWorker:
131
152
  logger.error("🚨 [APP] Error processing video preview message.", exc_info=True)
132
153
 
133
154
  def _start_stream(self, url, rtmp_server, stream_key, stream_duration, worker_id):
134
- """Runs RTSP-to-RTMP streaming in a separate thread."""
155
+ """Runs streaming to RTMP in a separate thread."""
135
156
  try:
136
157
  logger.info(f"🎥 [APP] Starting RTMP stream (Worker: {worker_id})")
137
158
 
138
159
  if url.startswith("worker-source/"):
139
160
  streamer = FileToRTMPStreamer(self.source_file_path / os.path.basename(url), rtmp_server, stream_key, stream_duration)
161
+ elif self._is_direct_device(url):
162
+ streamer = DirectDeviceToRTMPStreamer(url, rtmp_server, stream_key, stream_duration)
140
163
  else:
164
+ # Assume RTSP or other supported protocols
141
165
  streamer = RTSPtoRTMPStreamer(url, rtmp_server, stream_key, stream_duration)
142
166
 
143
167
  streamer.start_stream()