nedo-vision-worker-core 0.3.4__py3-none-any.whl → 0.3.6__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.
Potentially problematic release.
This version of nedo-vision-worker-core might be problematic. Click here for more details.
- nedo_vision_worker_core/__init__.py +1 -1
- nedo_vision_worker_core/database/DatabaseManager.py +17 -1
- nedo_vision_worker_core/pipeline/PipelineManager.py +63 -19
- nedo_vision_worker_core/pipeline/PipelineProcessor.py +23 -17
- nedo_vision_worker_core/pipeline/PipelineSyncThread.py +29 -32
- nedo_vision_worker_core/repositories/AIModelRepository.py +17 -17
- nedo_vision_worker_core/repositories/BaseRepository.py +44 -0
- nedo_vision_worker_core/repositories/PPEDetectionRepository.py +77 -79
- nedo_vision_worker_core/repositories/RestrictedAreaRepository.py +37 -38
- nedo_vision_worker_core/repositories/WorkerSourcePipelineDebugRepository.py +47 -46
- nedo_vision_worker_core/repositories/WorkerSourcePipelineDetectionRepository.py +14 -15
- nedo_vision_worker_core/repositories/WorkerSourcePipelineRepository.py +68 -36
- nedo_vision_worker_core/repositories/WorkerSourceRepository.py +9 -7
- nedo_vision_worker_core/streams/RTMPStreamer.py +283 -106
- nedo_vision_worker_core/streams/StreamSyncThread.py +51 -24
- nedo_vision_worker_core/streams/VideoStreamManager.py +76 -20
- {nedo_vision_worker_core-0.3.4.dist-info → nedo_vision_worker_core-0.3.6.dist-info}/METADATA +3 -2
- {nedo_vision_worker_core-0.3.4.dist-info → nedo_vision_worker_core-0.3.6.dist-info}/RECORD +21 -20
- {nedo_vision_worker_core-0.3.4.dist-info → nedo_vision_worker_core-0.3.6.dist-info}/WHEEL +0 -0
- {nedo_vision_worker_core-0.3.4.dist-info → nedo_vision_worker_core-0.3.6.dist-info}/entry_points.txt +0 -0
- {nedo_vision_worker_core-0.3.4.dist-info → nedo_vision_worker_core-0.3.6.dist-info}/top_level.txt +0 -0
|
@@ -7,16 +7,28 @@ import os
|
|
|
7
7
|
import sys
|
|
8
8
|
import cv2
|
|
9
9
|
import queue
|
|
10
|
-
from typing import Optional
|
|
10
|
+
from typing import Optional, Tuple, List
|
|
11
11
|
from ..util.PlatformDetector import PlatformDetector
|
|
12
12
|
|
|
13
|
+
# Set up a logger for this module
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
13
16
|
class RTMPStreamer:
|
|
14
17
|
"""
|
|
15
|
-
Streams raw BGR frames to an RTMP server using a
|
|
16
|
-
|
|
18
|
+
Streams raw BGR frames to an RTMP server using a robust FFmpeg subprocess.
|
|
19
|
+
|
|
20
|
+
Includes a 2-stage (HW -> CPU) fallback logic to handle
|
|
21
|
+
encoder failures, such as NVENC session limits.
|
|
22
|
+
This class is thread-safe.
|
|
17
23
|
"""
|
|
24
|
+
|
|
25
|
+
# Class-level lock to stagger stream initialization across all instances
|
|
26
|
+
_initialization_lock = threading.Lock()
|
|
27
|
+
_last_initialization_time = 0
|
|
28
|
+
_min_initialization_delay = 0.5 # 500ms between stream starts
|
|
18
29
|
|
|
19
30
|
def __init__(self, pipeline_id: str, fps: int = 25, bitrate: str = "1500k"):
|
|
31
|
+
self.pipeline_id = pipeline_id
|
|
20
32
|
self.rtmp_server = os.environ.get("RTMP_SERVER", "rtmp://localhost:1935/live")
|
|
21
33
|
self.rtmp_url = f"{self.rtmp_server}/{pipeline_id}"
|
|
22
34
|
self.fps = max(int(fps), 1)
|
|
@@ -26,140 +38,253 @@ class RTMPStreamer:
|
|
|
26
38
|
self.height: Optional[int] = None
|
|
27
39
|
self._platform = PlatformDetector()
|
|
28
40
|
|
|
29
|
-
|
|
30
|
-
self._gstreamer_writer: Optional[cv2.VideoWriter] = None
|
|
41
|
+
# --- Internal State ---
|
|
31
42
|
self._ffmpeg_process: Optional[subprocess.Popen] = None
|
|
43
|
+
self._active_encoder_name: Optional[str] = None
|
|
32
44
|
|
|
33
45
|
self._frame_queue = queue.Queue(maxsize=2)
|
|
34
46
|
self._writer_thread: Optional[threading.Thread] = None
|
|
47
|
+
self._stderr_thread: Optional[threading.Thread] = None
|
|
48
|
+
|
|
49
|
+
# --- Concurrency and Lifecycle ---
|
|
35
50
|
self._stop_evt = threading.Event()
|
|
51
|
+
self._lock = threading.Lock() # Instance-level lock for state changes
|
|
52
|
+
|
|
53
|
+
# --- State flags for failover ---
|
|
54
|
+
self._initialization_failed = False # Hard failure on init or fallback failure
|
|
55
|
+
self._hw_encoder_failed = threading.Event() # Use a thread-safe Event instance
|
|
36
56
|
|
|
37
57
|
def _kbps(self, rate_str: str) -> int:
|
|
58
|
+
"""Converts a bitrate string (e.g., '1500k') to an integer in kbps."""
|
|
38
59
|
return int(str(rate_str).lower().replace("k", "").strip())
|
|
39
60
|
|
|
40
61
|
# -------------------- Public API --------------------
|
|
41
62
|
|
|
42
63
|
def is_active(self) -> bool:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
64
|
+
"""
|
|
65
|
+
Checks if the stream is currently active and running.
|
|
66
|
+
This method is thread-safe.
|
|
67
|
+
"""
|
|
68
|
+
with self._lock:
|
|
46
69
|
return self._ffmpeg_process is not None and self._ffmpeg_process.poll() is None
|
|
47
|
-
return False
|
|
48
70
|
|
|
49
71
|
def push_frame(self, frame: np.ndarray):
|
|
50
|
-
|
|
72
|
+
"""
|
|
73
|
+
Pushes a raw BGR numpy array frame into the stream.
|
|
74
|
+
|
|
75
|
+
If this is the first frame, it will trigger the stream initialization.
|
|
76
|
+
If the stream's writer thread dies, this method will automatically
|
|
77
|
+
attempt to clean up and restart.
|
|
78
|
+
"""
|
|
79
|
+
# Don't accept frames if user stopped or init hard-failed
|
|
80
|
+
if self._stop_evt.is_set() or self._initialization_failed:
|
|
51
81
|
return
|
|
52
82
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
83
|
+
# Get the current writer thread object *outside* the lock
|
|
84
|
+
current_writer_snapshot = self._writer_thread
|
|
85
|
+
|
|
86
|
+
# The FFmpeg process can die from startup failure (immediate termination)
|
|
87
|
+
# or from runtime failure (pipe broken). In both cases, wait for the
|
|
88
|
+
# writer thread to fully exit before checking the HW flag to avoid race conditions.
|
|
89
|
+
process_active = self.is_active()
|
|
90
|
+
|
|
91
|
+
# If we have a writer thread but no active process, join it to ensure flags are set
|
|
92
|
+
if current_writer_snapshot is not None and threading.current_thread() != current_writer_snapshot:
|
|
93
|
+
if not process_active: # Process is dead or missing
|
|
94
|
+
current_writer_snapshot.join(timeout=1.0)
|
|
95
|
+
|
|
96
|
+
is_running = process_active and current_writer_snapshot is not None and current_writer_snapshot.is_alive()
|
|
57
97
|
|
|
98
|
+
if not is_running:
|
|
99
|
+
# Acquire the lock to perform the restart
|
|
100
|
+
with self._lock:
|
|
101
|
+
# Double-check: Another thread might have fixed it while we waited for the lock.
|
|
102
|
+
if self._writer_thread is not None and self._writer_thread.is_alive():
|
|
103
|
+
pass # Nothing to do, another thread fixed it
|
|
104
|
+
|
|
105
|
+
elif self._stop_evt.is_set() or self._initialization_failed:
|
|
106
|
+
pass # Stream is stopped or has hard-failed
|
|
107
|
+
|
|
108
|
+
else:
|
|
109
|
+
# Clean up the old process
|
|
110
|
+
self._internal_cleanup()
|
|
111
|
+
|
|
112
|
+
if frame is None:
|
|
113
|
+
return # Can't initialize with None
|
|
114
|
+
|
|
115
|
+
self.height, self.width = frame.shape[:2]
|
|
116
|
+
try:
|
|
117
|
+
self._start_stream()
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.error(f"❌ RTMP stream (re)start failed for {self.pipeline_id}: {e}")
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
# Put frame in queue, dropping the oldest if full
|
|
58
123
|
try:
|
|
59
124
|
self._frame_queue.put_nowait(frame)
|
|
60
125
|
except queue.Full:
|
|
61
126
|
try:
|
|
62
|
-
self._frame_queue.get_nowait()
|
|
63
|
-
self._frame_queue.put_nowait(frame)
|
|
64
|
-
except queue.Empty:
|
|
65
|
-
pass
|
|
66
|
-
|
|
127
|
+
self._frame_queue.get_nowait() # Discard oldest
|
|
128
|
+
self._frame_queue.put_nowait(frame) # Retry push
|
|
129
|
+
except (queue.Empty, queue.Full):
|
|
130
|
+
pass # Race condition, frame lost, which is fine
|
|
67
131
|
def stop_stream(self):
|
|
132
|
+
"""
|
|
133
|
+
Stops the stream, closes subprocesses, and joins the writer thread.
|
|
134
|
+
This method is idempotent and thread-safe.
|
|
135
|
+
"""
|
|
68
136
|
if self._stop_evt.is_set():
|
|
69
137
|
return
|
|
70
|
-
|
|
138
|
+
logger.info(f"Stopping RTMP stream for {self.rtmp_url}")
|
|
71
139
|
self._stop_evt.set()
|
|
72
140
|
|
|
141
|
+
# Send a sentinel value to unblock the writer thread
|
|
73
142
|
try:
|
|
74
143
|
self._frame_queue.put_nowait(None)
|
|
75
144
|
except queue.Full:
|
|
76
145
|
pass
|
|
77
146
|
|
|
78
|
-
|
|
79
|
-
|
|
147
|
+
# Wait for the writer thread to finish
|
|
148
|
+
thread_to_join = self._writer_thread
|
|
149
|
+
if thread_to_join and thread_to_join.is_alive() and threading.current_thread() != thread_to_join:
|
|
150
|
+
thread_to_join.join(timeout=2.0)
|
|
80
151
|
|
|
81
|
-
|
|
82
|
-
self.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
152
|
+
with self._lock:
|
|
153
|
+
self._internal_cleanup()
|
|
154
|
+
|
|
155
|
+
# -------------------- Internal Stream Management --------------------
|
|
156
|
+
|
|
157
|
+
def _internal_cleanup(self):
|
|
158
|
+
"""
|
|
159
|
+
Cleans up all stream resources.
|
|
160
|
+
MUST be called from within self._lock.
|
|
161
|
+
"""
|
|
86
162
|
if self._ffmpeg_process:
|
|
87
163
|
try:
|
|
88
|
-
if self._ffmpeg_process.stdin:
|
|
164
|
+
if self._ffmpeg_process.stdin:
|
|
165
|
+
self._ffmpeg_process.stdin.close()
|
|
89
166
|
self._ffmpeg_process.terminate()
|
|
90
|
-
self._ffmpeg_process.wait(timeout=
|
|
167
|
+
self._ffmpeg_process.wait(timeout=0.1)
|
|
91
168
|
except Exception:
|
|
92
|
-
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
if self._ffmpeg_process and self._ffmpeg_process.poll() is None:
|
|
173
|
+
self._ffmpeg_process.kill()
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
93
176
|
self._ffmpeg_process = None
|
|
94
|
-
|
|
177
|
+
logger.info(f"FFmpeg process stopped for {self.pipeline_id}.")
|
|
95
178
|
|
|
96
|
-
self.
|
|
97
|
-
|
|
98
|
-
|
|
179
|
+
self._active_encoder_name = None
|
|
180
|
+
self._writer_thread = None # Thread is dead or will be
|
|
181
|
+
self._stderr_thread = None # Daemon, will die
|
|
182
|
+
# Note: _hw_encoder_failed Event is NOT cleared. It's persistent.
|
|
99
183
|
|
|
100
184
|
def _start_stream(self):
|
|
101
|
-
|
|
185
|
+
"""
|
|
186
|
+
Starts the stream with a 2-stage FFmpeg fallback.
|
|
102
187
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
188
|
+
Fallback logic:
|
|
189
|
+
1. Try FFmpeg (HW)
|
|
190
|
+
2. Try FFmpeg (CPU)
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
# Stagger initialization
|
|
194
|
+
with RTMPStreamer._initialization_lock:
|
|
195
|
+
current_time = time.time()
|
|
196
|
+
time_since_last = current_time - RTMPStreamer._last_initialization_time
|
|
197
|
+
if time_since_last < RTMPStreamer._min_initialization_delay:
|
|
198
|
+
delay = RTMPStreamer._min_initialization_delay - time_since_last
|
|
199
|
+
logger.info(f"⏳ Staggering RTMP initialization for {self.pipeline_id} by {delay:.2f}s")
|
|
200
|
+
time.sleep(delay)
|
|
201
|
+
RTMPStreamer._last_initialization_time = time.time()
|
|
112
202
|
|
|
113
|
-
|
|
114
|
-
|
|
203
|
+
if self._stop_evt.is_set():
|
|
204
|
+
logger.info(f"Initialization cancelled for {self.pipeline_id}, stop was called.")
|
|
205
|
+
return
|
|
115
206
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
207
|
+
# --- Stage 1: Try FFmpeg (HW) ---
|
|
208
|
+
if not self._hw_encoder_failed.is_set():
|
|
209
|
+
cmd, encoder_name = self._build_ffmpeg_cmd(force_cpu=False)
|
|
210
|
+
|
|
211
|
+
if encoder_name != "libx264":
|
|
212
|
+
try:
|
|
213
|
+
self._ffmpeg_process = subprocess.Popen(
|
|
214
|
+
cmd,
|
|
215
|
+
stdin=subprocess.PIPE,
|
|
216
|
+
stdout=subprocess.DEVNULL,
|
|
217
|
+
stderr=subprocess.PIPE,
|
|
218
|
+
universal_newlines=False
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
time.sleep(0.5) # Give it a moment to start or fail
|
|
222
|
+
if self._ffmpeg_process.poll() is not None:
|
|
223
|
+
stderr_output = self._ffmpeg_process.stderr.read().decode('utf-8', errors='ignore') if self._ffmpeg_process.stderr else "No error output"
|
|
224
|
+
raise RuntimeError(f"FFmpeg process terminated immediately. Error: {stderr_output}")
|
|
225
|
+
|
|
226
|
+
self._start_writer_threads(encoder_name)
|
|
227
|
+
logger.info(f"✅ RTMP streaming started with FFmpeg ({encoder_name}): {self.rtmp_url}")
|
|
228
|
+
return
|
|
126
229
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
break
|
|
230
|
+
except Exception as e_hw:
|
|
231
|
+
logger.warning(f"FFmpeg ({encoder_name}) failed to start for {self.pipeline_id}: {e_hw}. Falling back to CPU.")
|
|
232
|
+
self._hw_encoder_failed.set() # Set Event on HW failure
|
|
233
|
+
if self._ffmpeg_process:
|
|
234
|
+
try: self._ffmpeg_process.kill()
|
|
235
|
+
except Exception: pass
|
|
236
|
+
self._ffmpeg_process = None
|
|
237
|
+
else:
|
|
238
|
+
logger.info(f"No HW encoder found for {self.pipeline_id}. Skipping straight to CPU.")
|
|
239
|
+
self._hw_encoder_failed.set() # Set Event if no HW
|
|
240
|
+
else:
|
|
241
|
+
logger.info(f"HW encoder previously failed for {self.pipeline_id}. Skipping straight to CPU.")
|
|
140
242
|
|
|
141
|
-
|
|
243
|
+
# --- Stage 2: Try FFmpeg (CPU) ---
|
|
244
|
+
try:
|
|
245
|
+
cmd_cpu, encoder_name_cpu = self._build_ffmpeg_cmd(force_cpu=True)
|
|
246
|
+
self._ffmpeg_process = subprocess.Popen(
|
|
247
|
+
cmd_cpu,
|
|
248
|
+
stdin=subprocess.PIPE,
|
|
249
|
+
stdout=subprocess.DEVNULL,
|
|
250
|
+
stderr=subprocess.PIPE,
|
|
251
|
+
universal_newlines=False
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
time.sleep(0.1)
|
|
255
|
+
if self._ffmpeg_process.poll() is not None:
|
|
256
|
+
stderr_output = self._ffmpeg_process.stderr.read().decode('utf-8', errors='ignore') if self._ffmpeg_process.stderr else "No error output"
|
|
257
|
+
raise RuntimeError(f"FFmpeg CPU process terminated immediately. Error: {stderr_output}")
|
|
142
258
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
f"
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
f"
|
|
156
|
-
|
|
259
|
+
self._start_writer_threads(encoder_name_cpu)
|
|
260
|
+
logger.info(f"✅ RTMP streaming started with FFmpeg (CPU Fallback: {encoder_name_cpu}): {self.rtmp_url}")
|
|
261
|
+
|
|
262
|
+
except Exception as e_cpu:
|
|
263
|
+
if self._ffmpeg_process:
|
|
264
|
+
try: self._ffmpeg_process.kill()
|
|
265
|
+
except Exception: pass
|
|
266
|
+
self._ffmpeg_process = None
|
|
267
|
+
|
|
268
|
+
logger.error(f"FATAL: Failed to start FFmpeg CPU fallback for {self.pipeline_id}: {e_cpu}")
|
|
269
|
+
self._initialization_failed = True
|
|
270
|
+
|
|
271
|
+
raise RuntimeError(f"Failed to start FFmpeg CPU fallback for {self.pipeline_id}") from e_cpu
|
|
272
|
+
|
|
273
|
+
def _start_writer_threads(self, encoder_name: str):
|
|
274
|
+
"""Helper to start the stderr and stdin writer threads."""
|
|
275
|
+
self._active_encoder_name = encoder_name
|
|
276
|
+
self._stderr_thread = threading.Thread(
|
|
277
|
+
target=self._log_ffmpeg_stderr,
|
|
278
|
+
args=(self._ffmpeg_process.stderr, self.pipeline_id),
|
|
279
|
+
daemon=True
|
|
157
280
|
)
|
|
158
|
-
|
|
281
|
+
self._stderr_thread.start()
|
|
282
|
+
|
|
283
|
+
self._writer_thread = threading.Thread(target=self._ffmpeg_pacing_loop, daemon=True)
|
|
284
|
+
self._writer_thread.start()
|
|
159
285
|
|
|
160
|
-
# --- FFmpeg Fallback Methods ---
|
|
161
|
-
|
|
162
286
|
def _ffmpeg_pacing_loop(self):
|
|
287
|
+
"""Writer thread loop for FFmpeg with manual frame pacing."""
|
|
163
288
|
frame_period = 1.0 / self.fps
|
|
164
289
|
last_frame_sent = None
|
|
165
290
|
|
|
@@ -168,6 +293,8 @@ class RTMPStreamer:
|
|
|
168
293
|
|
|
169
294
|
try:
|
|
170
295
|
frame = self._frame_queue.get_nowait()
|
|
296
|
+
if frame is None: # Sentinel value
|
|
297
|
+
break
|
|
171
298
|
last_frame_sent = frame
|
|
172
299
|
except queue.Empty:
|
|
173
300
|
frame = last_frame_sent
|
|
@@ -181,49 +308,99 @@ class RTMPStreamer:
|
|
|
181
308
|
raise BrokenPipeError("FFmpeg process is not active")
|
|
182
309
|
self._ffmpeg_process.stdin.write(frame.tobytes())
|
|
183
310
|
except (BrokenPipeError, OSError) as e:
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
311
|
+
|
|
312
|
+
# Check if this failure was from a HW encoder
|
|
313
|
+
is_hw_encoder = self._active_encoder_name and \
|
|
314
|
+
("nvenc" in self._active_encoder_name or
|
|
315
|
+
"omx" in self._active_encoder_name or
|
|
316
|
+
"videotoolbox" in self._active_encoder_name)
|
|
317
|
+
|
|
318
|
+
if is_hw_encoder:
|
|
319
|
+
logger.warning(f"Hardware encoder {self._active_encoder_name} failed at runtime for {self.pipeline_id}. Falling back to CPU.")
|
|
320
|
+
self._hw_encoder_failed.set()
|
|
321
|
+
else:
|
|
322
|
+
logger.error(f"FFmpeg ({self._active_encoder_name or 'unknown'}) process pipe broken for {self.pipeline_id}: {e}. Stream will restart.")
|
|
323
|
+
|
|
324
|
+
break
|
|
187
325
|
|
|
188
326
|
elapsed = time.monotonic() - start_time
|
|
189
327
|
sleep_duration = max(0, frame_period - elapsed)
|
|
190
328
|
time.sleep(sleep_duration)
|
|
191
329
|
|
|
192
|
-
def
|
|
193
|
-
|
|
194
|
-
|
|
330
|
+
def _log_ffmpeg_stderr(self, stderr_pipe, pipeline_id):
|
|
331
|
+
"""Background thread to continuously log FFmpeg's stderr."""
|
|
332
|
+
try:
|
|
333
|
+
for line in iter(stderr_pipe.readline, b''):
|
|
334
|
+
if not line:
|
|
335
|
+
break
|
|
336
|
+
logger.warning(f"[FFmpeg {pipeline_id}]: {line.decode('utf-8', errors='ignore').strip()}")
|
|
337
|
+
except Exception as e:
|
|
338
|
+
logger.info(f"FFmpeg stderr logging thread exited for {pipeline_id}: {e}")
|
|
339
|
+
finally:
|
|
340
|
+
if stderr_pipe:
|
|
341
|
+
stderr_pipe.close()
|
|
342
|
+
|
|
343
|
+
# -------------------- Pipeline Builders --------------------
|
|
344
|
+
|
|
345
|
+
def _build_ffmpeg_cmd(self, force_cpu: bool = False) -> Tuple[List[str], str]:
|
|
346
|
+
"""
|
|
347
|
+
Builds the FFmpeg command list.
|
|
348
|
+
Returns: (command_list, encoder_name)
|
|
349
|
+
"""
|
|
350
|
+
encoder_args, encoder_name = self._select_ffmpeg_encoder(force_cpu=force_cpu)
|
|
195
351
|
|
|
196
|
-
# Base command arguments
|
|
197
352
|
cmd = [
|
|
198
353
|
'ffmpeg', '-y', '-loglevel', 'error', '-nostats', '-hide_banner',
|
|
199
354
|
'-f', 'rawvideo', '-pixel_format', 'bgr24',
|
|
200
355
|
'-video_size', f'{self.width}x{self.height}',
|
|
201
356
|
'-framerate', str(self.fps), '-i', '-',
|
|
202
357
|
]
|
|
203
|
-
|
|
204
|
-
# Add the selected encoder
|
|
358
|
+
|
|
205
359
|
cmd.extend(encoder_args)
|
|
206
|
-
|
|
207
|
-
# Add common arguments for all encoders
|
|
360
|
+
|
|
208
361
|
cmd.extend([
|
|
209
362
|
'-profile:v', 'main', '-pix_fmt', 'yuv420p',
|
|
210
363
|
'-b:v', f"{self.bitrate}k", '-maxrate', f"{self.bitrate}k", '-bufsize', f"{self.bitrate*2}k",
|
|
211
|
-
'-g', str(self.fps), '-keyint_min', str(self.fps),
|
|
364
|
+
'-g', str(self.fps * 2), '-keyint_min', str(self.fps),
|
|
212
365
|
'-force_key_frames', 'expr:gte(t,n_forced*1)', '-an',
|
|
213
366
|
'-flvflags', 'no_duration_filesize', '-f', 'flv', self.rtmp_url,
|
|
214
367
|
])
|
|
215
368
|
|
|
216
|
-
# Conditionally add arguments that are ONLY valid for the libx264 encoder
|
|
217
369
|
if encoder_name == "libx264":
|
|
218
370
|
cmd.extend([
|
|
371
|
+
"-preset", "ultrafast",
|
|
219
372
|
"-tune", "zerolatency",
|
|
220
373
|
"-x264-params", "open_gop=0:aud=1:repeat-headers=1:nal-hrd=cbr",
|
|
221
374
|
])
|
|
222
375
|
|
|
223
|
-
return cmd
|
|
376
|
+
return cmd, encoder_name
|
|
224
377
|
|
|
225
|
-
def _select_ffmpeg_encoder(self) ->
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
378
|
+
def _select_ffmpeg_encoder(self, force_cpu: bool = False) -> Tuple[List[str], str]:
|
|
379
|
+
"""
|
|
380
|
+
Returns (encoder_args_list, encoder_name_str)
|
|
381
|
+
Will force CPU if force_cpu is True.
|
|
382
|
+
"""
|
|
383
|
+
if force_cpu:
|
|
384
|
+
return ["-c:v", "libx264"], "libx264"
|
|
385
|
+
|
|
386
|
+
force_encoder = os.environ.get("RTMP_ENCODER", "").lower()
|
|
387
|
+
|
|
388
|
+
if force_encoder == "cpu" or force_encoder == "libx264":
|
|
389
|
+
return ["-c:v", "libx264"], "libx264"
|
|
390
|
+
elif force_encoder == "nvenc":
|
|
391
|
+
return ["-c:v", "h264_nvenc", "-preset", "llhp"], "h264_nvenc"
|
|
392
|
+
|
|
393
|
+
if self._platform.is_jetson():
|
|
394
|
+
# Jetson-specific encoder
|
|
395
|
+
return ["-c:v", "h264_omx"], "h264_omx"
|
|
396
|
+
|
|
397
|
+
if sys.platform == "darwin":
|
|
398
|
+
return ["-c:v", "h264_videotoolbox"], "h264_videotoolbox"
|
|
399
|
+
|
|
400
|
+
has_nvidia = (os.environ.get("NVIDIA_VISIBLE_DEVICES") is not None or
|
|
401
|
+
os.path.exists("/proc/driver/nvidia/version"))
|
|
402
|
+
|
|
403
|
+
if has_nvidia:
|
|
404
|
+
return ["-c:v", "h264_nvenc", "-preset", "llhp"], "h264_nvenc"
|
|
405
|
+
|
|
406
|
+
return ["-c:v", "libx264"], "libx264"
|
|
@@ -29,40 +29,67 @@ class StreamSyncThread(threading.Thread):
|
|
|
29
29
|
try:
|
|
30
30
|
sources = self.worker_source_repo.get_worker_sources()
|
|
31
31
|
db_sources = {
|
|
32
|
-
source.id:
|
|
32
|
+
source.id:
|
|
33
33
|
source.url if source.type_code == "live"
|
|
34
34
|
else source.url if source.type_code == "direct"
|
|
35
|
-
else self._get_source_file_path(source.file_path)
|
|
36
|
-
|
|
37
|
-
) for source in sources
|
|
35
|
+
else self._get_source_file_path(source.file_path)
|
|
36
|
+
for source in sources
|
|
38
37
|
} # Store latest sources
|
|
38
|
+
|
|
39
|
+
# Get both active streams and pending streams
|
|
39
40
|
active_stream_ids = set(self.manager.get_active_stream_ids())
|
|
41
|
+
with self.manager._lock:
|
|
42
|
+
pending_stream_ids = set(self.manager.pending_streams.keys())
|
|
43
|
+
registered_stream_ids = active_stream_ids | pending_stream_ids
|
|
40
44
|
|
|
41
|
-
# **1️⃣
|
|
42
|
-
for source_id,
|
|
43
|
-
if source_id not in
|
|
44
|
-
logging.info(f"🟢
|
|
45
|
-
self.manager.
|
|
45
|
+
# **1️⃣ Register new streams (lazy loading - don't start them yet)**
|
|
46
|
+
for source_id, url in db_sources.items():
|
|
47
|
+
if source_id not in registered_stream_ids:
|
|
48
|
+
logging.info(f"🟢 Registering new stream: {source_id} ({url})")
|
|
49
|
+
self.manager.register_stream(source_id, url)
|
|
46
50
|
|
|
47
|
-
# **2️⃣
|
|
48
|
-
for stream_id in
|
|
49
|
-
if stream_id not in db_sources
|
|
50
|
-
logging.info(f"🔴
|
|
51
|
-
self.manager.
|
|
51
|
+
# **2️⃣ Unregister deleted or disconnected streams**
|
|
52
|
+
for stream_id in registered_stream_ids:
|
|
53
|
+
if stream_id not in db_sources:
|
|
54
|
+
logging.info(f"🔴 Unregistering stream: {stream_id}")
|
|
55
|
+
self.manager.unregister_stream(stream_id)
|
|
52
56
|
|
|
53
|
-
|
|
57
|
+
# Refresh registered streams
|
|
58
|
+
with self.manager._lock:
|
|
59
|
+
pending_stream_ids = set(self.manager.pending_streams.keys())
|
|
60
|
+
registered_stream_ids = active_stream_ids | pending_stream_ids
|
|
54
61
|
|
|
55
62
|
# **3️⃣ Update streams if URL has changed**
|
|
56
|
-
for source_id,
|
|
57
|
-
if source_id in
|
|
58
|
-
|
|
63
|
+
for source_id, url in db_sources.items():
|
|
64
|
+
if source_id in registered_stream_ids:
|
|
65
|
+
# Check if it's an active stream or pending stream
|
|
66
|
+
with self.manager._lock:
|
|
67
|
+
is_pending = source_id in self.manager.pending_streams
|
|
68
|
+
if is_pending:
|
|
69
|
+
existing_url = self.manager.pending_streams.get(source_id)
|
|
70
|
+
else:
|
|
71
|
+
existing_url = None
|
|
72
|
+
|
|
73
|
+
if existing_url is None:
|
|
74
|
+
# It's an active stream, get URL from stream manager
|
|
75
|
+
existing_url = self.manager.get_stream_url(source_id)
|
|
76
|
+
|
|
77
|
+
# Only update if URL actually changed
|
|
59
78
|
if existing_url != url:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
79
|
+
if is_pending:
|
|
80
|
+
# It's pending, just update the URL
|
|
81
|
+
with self.manager._lock:
|
|
82
|
+
self.manager.pending_streams[source_id] = url
|
|
83
|
+
logging.info(f"🟡 Updated pending stream {source_id} URL")
|
|
84
|
+
else:
|
|
85
|
+
# It's active, need to restart it
|
|
86
|
+
logging.info(f"🟡 Updating active stream {source_id}: New URL {url}")
|
|
87
|
+
# Unregister and re-register with new URL
|
|
88
|
+
self.manager.unregister_stream(source_id)
|
|
89
|
+
# Add a small delay for device cleanup
|
|
90
|
+
if self._is_direct_device(url) or self._is_direct_device(existing_url):
|
|
91
|
+
time.sleep(0.5) # Allow device to be properly released
|
|
92
|
+
self.manager.register_stream(source_id, url)
|
|
66
93
|
|
|
67
94
|
except Exception as e:
|
|
68
95
|
logging.error(f"⚠️ Error syncing streams from database: {e}")
|