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.
- matrice_streaming/__init__.py +44 -32
- matrice_streaming/streaming_gateway/camera_streamer/__init__.py +68 -1
- matrice_streaming/streaming_gateway/camera_streamer/async_camera_worker.py +1388 -0
- matrice_streaming/streaming_gateway/camera_streamer/async_ffmpeg_worker.py +966 -0
- matrice_streaming/streaming_gateway/camera_streamer/camera_streamer.py +188 -24
- matrice_streaming/streaming_gateway/camera_streamer/device_detection.py +507 -0
- matrice_streaming/streaming_gateway/camera_streamer/encoding_pool_manager.py +136 -0
- matrice_streaming/streaming_gateway/camera_streamer/ffmpeg_camera_streamer.py +1048 -0
- matrice_streaming/streaming_gateway/camera_streamer/ffmpeg_config.py +192 -0
- matrice_streaming/streaming_gateway/camera_streamer/ffmpeg_worker_manager.py +470 -0
- matrice_streaming/streaming_gateway/camera_streamer/gstreamer_camera_streamer.py +1368 -0
- matrice_streaming/streaming_gateway/camera_streamer/gstreamer_worker.py +1063 -0
- matrice_streaming/streaming_gateway/camera_streamer/gstreamer_worker_manager.py +546 -0
- matrice_streaming/streaming_gateway/camera_streamer/message_builder.py +60 -15
- matrice_streaming/streaming_gateway/camera_streamer/nvdec.py +1330 -0
- matrice_streaming/streaming_gateway/camera_streamer/nvdec_worker_manager.py +412 -0
- matrice_streaming/streaming_gateway/camera_streamer/platform_pipelines.py +680 -0
- matrice_streaming/streaming_gateway/camera_streamer/stream_statistics.py +111 -4
- matrice_streaming/streaming_gateway/camera_streamer/video_capture_manager.py +223 -27
- matrice_streaming/streaming_gateway/camera_streamer/worker_manager.py +694 -0
- matrice_streaming/streaming_gateway/debug/__init__.py +27 -2
- matrice_streaming/streaming_gateway/debug/benchmark.py +727 -0
- matrice_streaming/streaming_gateway/debug/debug_gstreamer_gateway.py +599 -0
- matrice_streaming/streaming_gateway/debug/debug_streaming_gateway.py +245 -95
- matrice_streaming/streaming_gateway/debug/debug_utils.py +29 -0
- matrice_streaming/streaming_gateway/debug/test_videoplayback.py +318 -0
- matrice_streaming/streaming_gateway/dynamic_camera_manager.py +656 -39
- matrice_streaming/streaming_gateway/metrics_reporter.py +676 -139
- matrice_streaming/streaming_gateway/streaming_action.py +71 -20
- matrice_streaming/streaming_gateway/streaming_gateway.py +1026 -78
- matrice_streaming/streaming_gateway/streaming_gateway_utils.py +175 -20
- matrice_streaming/streaming_gateway/streaming_status_listener.py +89 -0
- {matrice_streaming-0.1.14.dist-info → matrice_streaming-0.1.65.dist-info}/METADATA +1 -1
- matrice_streaming-0.1.65.dist-info/RECORD +56 -0
- matrice_streaming-0.1.14.dist-info/RECORD +0 -38
- {matrice_streaming-0.1.14.dist-info → matrice_streaming-0.1.65.dist-info}/WHEEL +0 -0
- {matrice_streaming-0.1.14.dist-info → matrice_streaming-0.1.65.dist-info}/licenses/LICENSE.txt +0 -0
- {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()
|