matrice-streaming 0.1.14__py3-none-any.whl → 0.1.65__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 (38) hide show
  1. matrice_streaming/__init__.py +44 -32
  2. matrice_streaming/streaming_gateway/camera_streamer/__init__.py +68 -1
  3. matrice_streaming/streaming_gateway/camera_streamer/async_camera_worker.py +1388 -0
  4. matrice_streaming/streaming_gateway/camera_streamer/async_ffmpeg_worker.py +966 -0
  5. matrice_streaming/streaming_gateway/camera_streamer/camera_streamer.py +188 -24
  6. matrice_streaming/streaming_gateway/camera_streamer/device_detection.py +507 -0
  7. matrice_streaming/streaming_gateway/camera_streamer/encoding_pool_manager.py +136 -0
  8. matrice_streaming/streaming_gateway/camera_streamer/ffmpeg_camera_streamer.py +1048 -0
  9. matrice_streaming/streaming_gateway/camera_streamer/ffmpeg_config.py +192 -0
  10. matrice_streaming/streaming_gateway/camera_streamer/ffmpeg_worker_manager.py +470 -0
  11. matrice_streaming/streaming_gateway/camera_streamer/gstreamer_camera_streamer.py +1368 -0
  12. matrice_streaming/streaming_gateway/camera_streamer/gstreamer_worker.py +1063 -0
  13. matrice_streaming/streaming_gateway/camera_streamer/gstreamer_worker_manager.py +546 -0
  14. matrice_streaming/streaming_gateway/camera_streamer/message_builder.py +60 -15
  15. matrice_streaming/streaming_gateway/camera_streamer/nvdec.py +1330 -0
  16. matrice_streaming/streaming_gateway/camera_streamer/nvdec_worker_manager.py +412 -0
  17. matrice_streaming/streaming_gateway/camera_streamer/platform_pipelines.py +680 -0
  18. matrice_streaming/streaming_gateway/camera_streamer/stream_statistics.py +111 -4
  19. matrice_streaming/streaming_gateway/camera_streamer/video_capture_manager.py +223 -27
  20. matrice_streaming/streaming_gateway/camera_streamer/worker_manager.py +694 -0
  21. matrice_streaming/streaming_gateway/debug/__init__.py +27 -2
  22. matrice_streaming/streaming_gateway/debug/benchmark.py +727 -0
  23. matrice_streaming/streaming_gateway/debug/debug_gstreamer_gateway.py +599 -0
  24. matrice_streaming/streaming_gateway/debug/debug_streaming_gateway.py +245 -95
  25. matrice_streaming/streaming_gateway/debug/debug_utils.py +29 -0
  26. matrice_streaming/streaming_gateway/debug/test_videoplayback.py +318 -0
  27. matrice_streaming/streaming_gateway/dynamic_camera_manager.py +656 -39
  28. matrice_streaming/streaming_gateway/metrics_reporter.py +676 -139
  29. matrice_streaming/streaming_gateway/streaming_action.py +71 -20
  30. matrice_streaming/streaming_gateway/streaming_gateway.py +1026 -78
  31. matrice_streaming/streaming_gateway/streaming_gateway_utils.py +175 -20
  32. matrice_streaming/streaming_gateway/streaming_status_listener.py +89 -0
  33. {matrice_streaming-0.1.14.dist-info → matrice_streaming-0.1.65.dist-info}/METADATA +1 -1
  34. matrice_streaming-0.1.65.dist-info/RECORD +56 -0
  35. matrice_streaming-0.1.14.dist-info/RECORD +0 -38
  36. {matrice_streaming-0.1.14.dist-info → matrice_streaming-0.1.65.dist-info}/WHEEL +0 -0
  37. {matrice_streaming-0.1.14.dist-info → matrice_streaming-0.1.65.dist-info}/licenses/LICENSE.txt +0 -0
  38. {matrice_streaming-0.1.14.dist-info → matrice_streaming-0.1.65.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,192 @@
1
+ """FFmpeg configuration for streaming gateway.
2
+
3
+ This module provides configuration dataclasses for FFmpeg-based video streaming,
4
+ following the same pattern as GStreamerConfig for consistency.
5
+ """
6
+ from dataclasses import dataclass, field
7
+ from typing import Optional, List
8
+
9
+
10
+ @dataclass
11
+ class FFmpegConfig:
12
+ """Configuration for FFmpeg-based streaming.
13
+
14
+ This configuration controls how FFmpeg subprocesses are spawned
15
+ for video ingestion, with options for hardware acceleration,
16
+ low-latency settings, and output format control.
17
+ """
18
+
19
+ # Decoding settings
20
+ threads: int = 1 # Single decode thread per stream (prevents thread explosion)
21
+ buffer_frames: int = 4 # Pipe buffer size in frames
22
+ hwaccel: str = "auto" # Hardware acceleration: auto, cuda, vaapi, videotoolbox, none
23
+
24
+ # Input settings
25
+ realtime: bool = False # -re flag for source FPS timing (simulates live camera)
26
+ loop: bool = True # Loop video files indefinitely
27
+ low_latency: bool = True # Enable nobuffer, low_delay flags
28
+
29
+ # Output format
30
+ pixel_format: str = "bgr24" # Output format: bgr24 (OpenCV), rgb24, nv12
31
+ output_width: int = 0 # Downscale width (0 = use source resolution)
32
+ output_height: int = 0 # Downscale height (0 = use source resolution)
33
+
34
+ # JPEG encoding settings (when encoding output)
35
+ quality: int = 90 # JPEG quality (1-100)
36
+ encode_output: bool = False # If True, encode frames to JPEG before sending
37
+
38
+ # Connection settings
39
+ tcp_timeout: int = 5 # TCP timeout in seconds for RTSP/HTTP
40
+ rtsp_transport: str = "tcp" # RTSP transport: tcp, udp, http
41
+ reconnect_delay: float = 1.0 # Delay between reconnection attempts
42
+
43
+ # Debug/logging
44
+ loglevel: str = "error" # FFmpeg log level: quiet, error, warning, info, debug
45
+ probesize: int = 5000000 # Probe size in bytes for format detection
46
+ analyzeduration: int = 5000000 # Analyze duration in microseconds
47
+
48
+ def to_ffmpeg_args(self, source: str, width: int = 0, height: int = 0) -> List[str]:
49
+ """Build FFmpeg command line arguments.
50
+
51
+ Args:
52
+ source: Input source (file path, RTSP URL, etc.)
53
+ width: Frame width (0 = auto-detect)
54
+ height: Frame height (0 = auto-detect)
55
+
56
+ Returns:
57
+ List of command line arguments for FFmpeg
58
+ """
59
+ cmd = ["ffmpeg"]
60
+
61
+ # Logging
62
+ cmd.extend(["-loglevel", self.loglevel])
63
+ cmd.extend(["-nostdin"])
64
+
65
+ # Realtime simulation
66
+ if self.realtime:
67
+ cmd.extend(["-re"])
68
+
69
+ # Looping for video files
70
+ if self.loop and not source.startswith("rtsp://") and not source.startswith("http://"):
71
+ cmd.extend(["-stream_loop", "-1"])
72
+
73
+ # Low-latency flags
74
+ if self.low_latency:
75
+ cmd.extend(["-fflags", "nobuffer"])
76
+ cmd.extend(["-flags", "low_delay"])
77
+
78
+ # Probe settings
79
+ cmd.extend(["-probesize", str(self.probesize)])
80
+ cmd.extend(["-analyzeduration", str(self.analyzeduration)])
81
+
82
+ # Hardware acceleration
83
+ if self.hwaccel != "none":
84
+ cmd.extend(["-hwaccel", self.hwaccel])
85
+
86
+ # RTSP-specific settings
87
+ if source.startswith("rtsp://"):
88
+ cmd.extend(["-rtsp_transport", self.rtsp_transport])
89
+ cmd.extend(["-stimeout", str(self.tcp_timeout * 1000000)]) # microseconds
90
+
91
+ # Input source
92
+ cmd.extend(["-i", source])
93
+
94
+ # Disable audio
95
+ cmd.extend(["-an"])
96
+
97
+ # Video sync mode
98
+ cmd.extend(["-vsync", "0"])
99
+
100
+ # Thread count for decoding
101
+ cmd.extend(["-threads", str(self.threads)])
102
+
103
+ # Downscale filter
104
+ output_w = self.output_width if self.output_width > 0 else width
105
+ output_h = self.output_height if self.output_height > 0 else height
106
+ if output_w > 0 and output_h > 0:
107
+ cmd.extend(["-vf", f"scale={output_w}:{output_h}"])
108
+
109
+ # Output format: raw video to pipe
110
+ cmd.extend(["-f", "rawvideo"])
111
+ cmd.extend(["-pix_fmt", self.pixel_format])
112
+ cmd.extend(["pipe:1"])
113
+
114
+ return cmd
115
+
116
+
117
+ def is_ffmpeg_available() -> bool:
118
+ """Check if FFmpeg is available on the system.
119
+
120
+ Returns:
121
+ True if FFmpeg is available, False otherwise
122
+ """
123
+ import subprocess
124
+ try:
125
+ result = subprocess.run(
126
+ ["ffmpeg", "-version"],
127
+ capture_output=True,
128
+ timeout=5
129
+ )
130
+ return result.returncode == 0
131
+ except Exception:
132
+ return False
133
+
134
+
135
+ def get_ffmpeg_version() -> Optional[str]:
136
+ """Get FFmpeg version string.
137
+
138
+ Returns:
139
+ Version string or None if FFmpeg is not available
140
+ """
141
+ import subprocess
142
+ try:
143
+ result = subprocess.run(
144
+ ["ffmpeg", "-version"],
145
+ capture_output=True,
146
+ text=True,
147
+ timeout=5
148
+ )
149
+ if result.returncode == 0:
150
+ # First line contains version
151
+ first_line = result.stdout.split('\n')[0]
152
+ return first_line
153
+ return None
154
+ except Exception:
155
+ return None
156
+
157
+
158
+ def detect_hwaccel() -> str:
159
+ """Detect available hardware acceleration.
160
+
161
+ Returns:
162
+ Best available hwaccel option: cuda, vaapi, videotoolbox, or none
163
+ """
164
+ import subprocess
165
+ import sys
166
+
167
+ # Check for NVIDIA CUDA (Linux/Windows)
168
+ try:
169
+ result = subprocess.run(
170
+ ["nvidia-smi"],
171
+ capture_output=True,
172
+ timeout=5
173
+ )
174
+ if result.returncode == 0:
175
+ return "cuda"
176
+ except Exception:
177
+ pass
178
+
179
+ # Check for VAAPI (Linux)
180
+ if sys.platform.startswith("linux"):
181
+ try:
182
+ import os
183
+ if os.path.exists("/dev/dri/renderD128"):
184
+ return "vaapi"
185
+ except Exception:
186
+ pass
187
+
188
+ # Check for VideoToolbox (macOS)
189
+ if sys.platform == "darwin":
190
+ return "videotoolbox"
191
+
192
+ return "none"
@@ -0,0 +1,470 @@
1
+ """Worker manager for coordinating multiple FFmpeg async camera workers.
2
+
3
+ This module manages a pool of FFmpeg-based async worker processes,
4
+ distributing cameras across them and monitoring their health.
5
+ """
6
+ import logging
7
+ import multiprocessing
8
+ import os
9
+ import sys
10
+ import time
11
+ import signal
12
+ from typing import List, Dict, Any, Optional
13
+ from dataclasses import asdict
14
+
15
+ from .async_ffmpeg_worker import run_ffmpeg_worker
16
+ from .ffmpeg_config import FFmpegConfig, is_ffmpeg_available
17
+
18
+
19
+ class FFmpegWorkerManager:
20
+ """Manages multiple FFmpeg async camera worker processes.
21
+
22
+ This manager coordinates worker processes, distributing cameras
23
+ across them for optimal throughput using FFmpeg subprocesses
24
+ for video ingestion.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ camera_configs: List[Dict[str, Any]],
30
+ stream_config: Dict[str, Any],
31
+ num_workers: Optional[int] = None,
32
+ cpu_percentage: float = 0.9,
33
+ max_cameras_per_worker: int = 100,
34
+ # FFmpeg configuration
35
+ ffmpeg_config: Optional[FFmpegConfig] = None,
36
+ # SHM options
37
+ use_shm: bool = False,
38
+ shm_slot_count: int = 1000,
39
+ shm_frame_format: str = "BGR",
40
+ # Performance options
41
+ pin_cpu_affinity: bool = True,
42
+ ):
43
+ """Initialize FFmpeg worker manager.
44
+
45
+ Args:
46
+ camera_configs: List of all camera configurations
47
+ stream_config: Streaming configuration (Redis, Kafka, etc.)
48
+ num_workers: Number of worker processes (default: auto-calculated)
49
+ cpu_percentage: Percentage of CPU cores to use (default: 0.9)
50
+ max_cameras_per_worker: Maximum cameras per worker (default: 100)
51
+ ffmpeg_config: FFmpeg configuration options
52
+ use_shm: Enable SHM mode for raw frame sharing
53
+ shm_slot_count: Number of frame slots per camera ring buffer
54
+ shm_frame_format: Frame format for SHM storage
55
+ pin_cpu_affinity: Pin workers to specific CPU cores
56
+ """
57
+ self.camera_configs = camera_configs
58
+ self.stream_config = stream_config
59
+ self.ffmpeg_config = ffmpeg_config or FFmpegConfig()
60
+
61
+ # Validate FFmpeg availability
62
+ if not is_ffmpeg_available():
63
+ raise RuntimeError("FFmpeg is not available on this system")
64
+
65
+ self.logger = logging.getLogger(__name__)
66
+
67
+ # Calculate worker count
68
+ if num_workers is None:
69
+ cpu_count = os.cpu_count() or 4
70
+ num_cameras = len(camera_configs)
71
+
72
+ if cpu_count >= 16 or num_cameras >= 100:
73
+ target_cameras_per_worker = 25
74
+ calculated_workers = max(4, min(num_cameras // target_cameras_per_worker, 50))
75
+ else:
76
+ calculated_workers = max(4, int(cpu_count * cpu_percentage))
77
+
78
+ self.num_workers = min(calculated_workers, num_cameras) if num_cameras > 0 else calculated_workers
79
+ else:
80
+ self.num_workers = num_workers
81
+
82
+ self.max_cameras_per_worker = max_cameras_per_worker
83
+
84
+ # Log scaling info
85
+ cpu_count = os.cpu_count() or 4
86
+ self.logger.info(
87
+ f"FFmpeg worker scaling: {cpu_count} CPU cores, "
88
+ f"using {self.num_workers} workers for {len(camera_configs)} cameras"
89
+ )
90
+
91
+ # SHM configuration
92
+ self.use_shm = use_shm
93
+ self.shm_slot_count = shm_slot_count
94
+ self.shm_frame_format = shm_frame_format
95
+
96
+ # Performance configuration
97
+ self.pin_cpu_affinity = pin_cpu_affinity
98
+
99
+ if pin_cpu_affinity:
100
+ self.logger.info("CPU affinity pinning ENABLED")
101
+
102
+ # Multiprocessing primitives
103
+ self.stop_event = multiprocessing.Event()
104
+ self.health_queue = multiprocessing.Queue()
105
+
106
+ # Worker processes
107
+ self.workers: List[multiprocessing.Process] = []
108
+ self.worker_camera_assignments: Dict[int, List[Dict[str, Any]]] = {}
109
+
110
+ # Health monitoring
111
+ self.last_health_reports: Dict[int, Dict[str, Any]] = {}
112
+
113
+ # Dynamic camera support
114
+ self.command_queues: Dict[int, multiprocessing.Queue] = {}
115
+ self.response_queue = multiprocessing.Queue()
116
+
117
+ # Camera tracking
118
+ self.camera_to_worker: Dict[str, int] = {}
119
+ self.worker_camera_count: Dict[int, int] = {}
120
+
121
+ self.logger.info(
122
+ f"FFmpegWorkerManager initialized: {self.num_workers} workers, "
123
+ f"{len(camera_configs)} cameras, hwaccel={self.ffmpeg_config.hwaccel}"
124
+ )
125
+
126
+ def start(self):
127
+ """Start all workers and begin streaming."""
128
+ try:
129
+ self._distribute_cameras()
130
+
131
+ self.logger.info(f"Starting {self.num_workers} FFmpeg worker processes...")
132
+ for worker_id in range(self.num_workers):
133
+ self._start_worker(worker_id)
134
+
135
+ self.logger.info(
136
+ f"All FFmpeg workers started! "
137
+ f"Streaming {len(self.camera_configs)} cameras across {self.num_workers} workers"
138
+ )
139
+ except Exception as e:
140
+ self.logger.error(f"Failed to start FFmpeg workers: {e}")
141
+ self.stop()
142
+ raise
143
+
144
+ def _distribute_cameras(self):
145
+ """Distribute cameras across workers using static partitioning."""
146
+ total_cameras = len(self.camera_configs)
147
+ cameras_per_worker = total_cameras // self.num_workers
148
+ remainder = total_cameras % self.num_workers
149
+
150
+ self.logger.info(
151
+ f"Distributing {total_cameras} cameras: "
152
+ f"~{cameras_per_worker} per worker"
153
+ )
154
+
155
+ camera_idx = 0
156
+ for worker_id in range(self.num_workers):
157
+ num_cameras = cameras_per_worker + (1 if worker_id < remainder else 0)
158
+ worker_cameras = self.camera_configs[camera_idx:camera_idx + num_cameras]
159
+ self.worker_camera_assignments[worker_id] = worker_cameras
160
+
161
+ self.logger.info(
162
+ f"Worker {worker_id}: {len(worker_cameras)} cameras "
163
+ f"(indices {camera_idx} to {camera_idx + num_cameras - 1})"
164
+ )
165
+
166
+ camera_idx += num_cameras
167
+
168
+ def _start_worker(self, worker_id: int):
169
+ """Start a single FFmpeg worker process.
170
+
171
+ Args:
172
+ worker_id: Worker identifier
173
+ """
174
+ worker_cameras = self.worker_camera_assignments.get(worker_id, [])
175
+
176
+ command_queue = multiprocessing.Queue()
177
+ self.command_queues[worker_id] = command_queue
178
+
179
+ self.worker_camera_count[worker_id] = len(worker_cameras)
180
+
181
+ for cam_config in worker_cameras:
182
+ stream_key = cam_config.get('stream_key')
183
+ if stream_key:
184
+ self.camera_to_worker[stream_key] = worker_id
185
+
186
+ # Convert FFmpeg config to dict for pickling
187
+ ffmpeg_config_dict = asdict(self.ffmpeg_config)
188
+
189
+ try:
190
+ if sys.platform == 'win32':
191
+ ctx = multiprocessing.get_context('spawn')
192
+ else:
193
+ ctx = multiprocessing.get_context('fork')
194
+
195
+ worker = ctx.Process(
196
+ target=run_ffmpeg_worker,
197
+ args=(
198
+ worker_id,
199
+ worker_cameras,
200
+ self.stream_config,
201
+ self.stop_event,
202
+ self.health_queue,
203
+ command_queue,
204
+ self.response_queue,
205
+ ffmpeg_config_dict,
206
+ self.use_shm,
207
+ self.shm_slot_count,
208
+ self.shm_frame_format,
209
+ self.pin_cpu_affinity,
210
+ self.num_workers,
211
+ ),
212
+ name=f"FFmpegWorker-{worker_id}",
213
+ daemon=False,
214
+ )
215
+ worker.start()
216
+ self.workers.append(worker)
217
+
218
+ self.logger.info(
219
+ f"Started FFmpeg worker {worker_id} (PID: {worker.pid}) "
220
+ f"with {len(worker_cameras)} cameras"
221
+ )
222
+ except Exception as e:
223
+ self.logger.error(f"Failed to start FFmpeg worker {worker_id}: {e}")
224
+ raise
225
+
226
+ def monitor(self, duration: Optional[float] = None):
227
+ """Monitor workers and collect health reports.
228
+
229
+ Args:
230
+ duration: How long to monitor (None = indefinite)
231
+ """
232
+ self.logger.info("Starting health monitoring...")
233
+
234
+ start_time = time.time()
235
+ last_summary_time = start_time
236
+
237
+ try:
238
+ while not self.stop_event.is_set():
239
+ if duration and (time.time() - start_time) >= duration:
240
+ self.logger.info(f"Monitoring duration ({duration}s) complete")
241
+ break
242
+
243
+ # Collect health reports
244
+ while not self.health_queue.empty():
245
+ try:
246
+ report = self.health_queue.get_nowait()
247
+ worker_id = report['worker_id']
248
+ self.last_health_reports[worker_id] = report
249
+
250
+ if report['status'] in ['error', 'stopped']:
251
+ self.logger.warning(
252
+ f"Worker {worker_id} status: {report['status']} "
253
+ f"(error: {report.get('error', 'None')})"
254
+ )
255
+ except Exception as e:
256
+ self.logger.error(f"Error processing health report: {e}")
257
+
258
+ # Check worker processes
259
+ for i, worker in enumerate(self.workers):
260
+ if not worker.is_alive() and not self.stop_event.is_set():
261
+ self.logger.error(
262
+ f"FFmpeg worker {i} (PID: {worker.pid}) died! "
263
+ f"Exit code: {worker.exitcode}"
264
+ )
265
+
266
+ # Print summary periodically
267
+ if time.time() - last_summary_time >= 10.0:
268
+ self._print_health_summary()
269
+ last_summary_time = time.time()
270
+
271
+ time.sleep(0.5)
272
+
273
+ except KeyboardInterrupt:
274
+ self.logger.info("Monitoring interrupted by user")
275
+
276
+ def _print_health_summary(self):
277
+ """Print summary of worker health."""
278
+ running_workers = sum(1 for w in self.workers if w.is_alive())
279
+ total_cameras = sum(
280
+ report.get('active_cameras', 0)
281
+ for report in self.last_health_reports.values()
282
+ )
283
+
284
+ self.logger.info(
285
+ f"FFmpeg Health Summary: {running_workers}/{len(self.workers)} workers alive, "
286
+ f"{total_cameras} active cameras"
287
+ )
288
+
289
+ def stop(self, timeout: float = 15.0):
290
+ """Stop all workers gracefully.
291
+
292
+ Args:
293
+ timeout: Maximum time to wait per worker (seconds)
294
+ """
295
+ self.logger.info("Stopping all FFmpeg workers...")
296
+
297
+ self.stop_event.set()
298
+
299
+ for i, worker in enumerate(self.workers):
300
+ if worker.is_alive():
301
+ self.logger.info(f"Waiting for FFmpeg worker {i} to stop...")
302
+ worker.join(timeout=timeout)
303
+
304
+ if worker.is_alive():
305
+ self.logger.warning(f"FFmpeg worker {i} did not stop, terminating...")
306
+ worker.terminate()
307
+ worker.join(timeout=5.0)
308
+
309
+ if worker.is_alive():
310
+ self.logger.error(f"FFmpeg worker {i} could not be stopped!")
311
+ else:
312
+ self.logger.info(f"FFmpeg worker {i} terminated")
313
+ else:
314
+ self.logger.info(f"FFmpeg worker {i} stopped gracefully")
315
+
316
+ self.logger.info("=" * 60)
317
+ self.logger.info("FFMPEG WORKERS SHUTDOWN COMPLETE")
318
+ self.logger.info("=" * 60)
319
+
320
+ def add_camera(self, camera_config: Dict[str, Any]) -> bool:
321
+ """Add a camera to the least-loaded worker at runtime.
322
+
323
+ Args:
324
+ camera_config: Camera configuration dictionary
325
+
326
+ Returns:
327
+ bool: True if camera was added successfully
328
+ """
329
+ stream_key = camera_config.get('stream_key')
330
+ if not stream_key:
331
+ self.logger.error("Camera config missing stream_key")
332
+ return False
333
+
334
+ if stream_key in self.camera_to_worker:
335
+ self.logger.warning(f"Camera {stream_key} already exists")
336
+ return False
337
+
338
+ target_worker_id = self._find_least_loaded_worker()
339
+ if target_worker_id is None:
340
+ self.logger.error("All workers at capacity")
341
+ return False
342
+
343
+ command = {
344
+ 'type': 'add_camera',
345
+ 'camera_config': camera_config,
346
+ 'timestamp': time.time()
347
+ }
348
+
349
+ try:
350
+ self.command_queues[target_worker_id].put(command, timeout=5.0)
351
+ self.camera_to_worker[stream_key] = target_worker_id
352
+ self.worker_camera_count[target_worker_id] += 1
353
+ self.logger.info(f"Sent add_camera for {stream_key} to FFmpeg worker {target_worker_id}")
354
+ return True
355
+ except Exception as e:
356
+ self.logger.error(f"Failed to send add_camera command: {e}")
357
+ return False
358
+
359
+ def remove_camera(self, stream_key: str) -> bool:
360
+ """Remove a camera from its assigned worker.
361
+
362
+ Args:
363
+ stream_key: Unique identifier for the camera stream
364
+
365
+ Returns:
366
+ bool: True if camera removal was initiated
367
+ """
368
+ if stream_key not in self.camera_to_worker:
369
+ self.logger.warning(f"Camera {stream_key} not found")
370
+ return False
371
+
372
+ worker_id = self.camera_to_worker[stream_key]
373
+
374
+ command = {
375
+ 'type': 'remove_camera',
376
+ 'stream_key': stream_key,
377
+ 'timestamp': time.time()
378
+ }
379
+
380
+ try:
381
+ self.command_queues[worker_id].put(command, timeout=5.0)
382
+ del self.camera_to_worker[stream_key]
383
+ self.worker_camera_count[worker_id] -= 1
384
+ self.logger.info(f"Sent remove_camera for {stream_key} to FFmpeg worker {worker_id}")
385
+ return True
386
+ except Exception as e:
387
+ self.logger.error(f"Failed to send remove_camera command: {e}")
388
+ return False
389
+
390
+ def _find_least_loaded_worker(self) -> Optional[int]:
391
+ """Find the worker with the least cameras that's not at capacity."""
392
+ available_workers = []
393
+ for worker_id, count in self.worker_camera_count.items():
394
+ if count < self.max_cameras_per_worker and worker_id in self.command_queues:
395
+ if worker_id < len(self.workers) and self.workers[worker_id].is_alive():
396
+ available_workers.append((worker_id, count))
397
+
398
+ if not available_workers:
399
+ return None
400
+
401
+ return min(available_workers, key=lambda x: x[1])[0]
402
+
403
+ def get_camera_assignments(self) -> Dict[str, int]:
404
+ """Get current camera-to-worker assignments."""
405
+ return self.camera_to_worker.copy()
406
+
407
+ def get_worker_statistics(self) -> Dict[str, Any]:
408
+ """Get detailed statistics about workers and cameras."""
409
+ while not self.health_queue.empty():
410
+ try:
411
+ report = self.health_queue.get_nowait()
412
+ worker_id = report.get('worker_id')
413
+ if worker_id is not None:
414
+ self.last_health_reports[worker_id] = report
415
+ except Exception:
416
+ break
417
+
418
+ return {
419
+ 'num_workers': len(self.workers),
420
+ 'running_workers': sum(1 for w in self.workers if w.is_alive()),
421
+ 'total_cameras': sum(self.worker_camera_count.values()),
422
+ 'camera_assignments': self.camera_to_worker.copy(),
423
+ 'worker_camera_counts': self.worker_camera_count.copy(),
424
+ 'backend': 'ffmpeg',
425
+ 'ffmpeg_config': {
426
+ 'hwaccel': self.ffmpeg_config.hwaccel,
427
+ 'pixel_format': self.ffmpeg_config.pixel_format,
428
+ 'low_latency': self.ffmpeg_config.low_latency,
429
+ },
430
+ 'health_reports': {
431
+ worker_id: {
432
+ 'status': report.get('status', 'unknown'),
433
+ 'active_cameras': report.get('active_cameras', 0),
434
+ 'timestamp': report.get('timestamp', 0),
435
+ 'metrics': report.get('metrics', {}),
436
+ }
437
+ for worker_id, report in self.last_health_reports.items()
438
+ },
439
+ }
440
+
441
+ def run(self, duration: Optional[float] = None):
442
+ """Start workers and monitor until stopped.
443
+
444
+ Args:
445
+ duration: How long to run (None = until interrupted)
446
+ """
447
+ try:
448
+ signal.signal(signal.SIGINT, self._signal_handler)
449
+ signal.signal(signal.SIGTERM, self._signal_handler)
450
+
451
+ self.start()
452
+ self.monitor(duration=duration)
453
+
454
+ except Exception as e:
455
+ self.logger.error(f"Error in run loop: {e}", exc_info=True)
456
+ finally:
457
+ self.stop()
458
+
459
+ def _signal_handler(self, signum, frame):
460
+ """Handle shutdown signals gracefully."""
461
+ signal_name = signal.Signals(signum).name
462
+ self.logger.info(f"Received {signal_name}, initiating graceful shutdown...")
463
+ self.stop_event.set()
464
+
465
+ def __enter__(self):
466
+ self.start()
467
+ return self
468
+
469
+ def __exit__(self, exc_type, exc_val, exc_tb):
470
+ self.stop()