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.

Files changed (21) hide show
  1. nedo_vision_worker_core/__init__.py +1 -1
  2. nedo_vision_worker_core/database/DatabaseManager.py +17 -1
  3. nedo_vision_worker_core/pipeline/PipelineManager.py +63 -19
  4. nedo_vision_worker_core/pipeline/PipelineProcessor.py +23 -17
  5. nedo_vision_worker_core/pipeline/PipelineSyncThread.py +29 -32
  6. nedo_vision_worker_core/repositories/AIModelRepository.py +17 -17
  7. nedo_vision_worker_core/repositories/BaseRepository.py +44 -0
  8. nedo_vision_worker_core/repositories/PPEDetectionRepository.py +77 -79
  9. nedo_vision_worker_core/repositories/RestrictedAreaRepository.py +37 -38
  10. nedo_vision_worker_core/repositories/WorkerSourcePipelineDebugRepository.py +47 -46
  11. nedo_vision_worker_core/repositories/WorkerSourcePipelineDetectionRepository.py +14 -15
  12. nedo_vision_worker_core/repositories/WorkerSourcePipelineRepository.py +68 -36
  13. nedo_vision_worker_core/repositories/WorkerSourceRepository.py +9 -7
  14. nedo_vision_worker_core/streams/RTMPStreamer.py +283 -106
  15. nedo_vision_worker_core/streams/StreamSyncThread.py +51 -24
  16. nedo_vision_worker_core/streams/VideoStreamManager.py +76 -20
  17. {nedo_vision_worker_core-0.3.4.dist-info → nedo_vision_worker_core-0.3.6.dist-info}/METADATA +3 -2
  18. {nedo_vision_worker_core-0.3.4.dist-info → nedo_vision_worker_core-0.3.6.dist-info}/RECORD +21 -20
  19. {nedo_vision_worker_core-0.3.4.dist-info → nedo_vision_worker_core-0.3.6.dist-info}/WHEEL +0 -0
  20. {nedo_vision_worker_core-0.3.4.dist-info → nedo_vision_worker_core-0.3.6.dist-info}/entry_points.txt +0 -0
  21. {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 GStreamer-first approach
16
- with a reliable FFmpeg subprocess fallback.
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
- self._backend = None # "gstreamer" or "ffmpeg"
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
- if self._backend == "gstreamer":
44
- return self._gstreamer_writer is not None and self._gstreamer_writer.isOpened()
45
- if self._backend == "ffmpeg":
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
- if self._stop_evt.is_set():
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
- if self._writer_thread is None or not self._writer_thread.is_alive():
54
- if frame is None: return
55
- self.height, self.width = frame.shape[:2]
56
- self._start_stream()
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
- logging.info(f"Stopping RTMP stream for {self.rtmp_url}")
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
- if self._writer_thread and self._writer_thread.is_alive() and threading.current_thread() != self._writer_thread:
79
- self._writer_thread.join(timeout=2.0)
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
- if self._gstreamer_writer:
82
- self._gstreamer_writer.release()
83
- self._gstreamer_writer = None
84
- logging.info("GStreamer writer released.")
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: self._ffmpeg_process.stdin.close()
164
+ if self._ffmpeg_process.stdin:
165
+ self._ffmpeg_process.stdin.close()
89
166
  self._ffmpeg_process.terminate()
90
- self._ffmpeg_process.wait(timeout=2.0)
167
+ self._ffmpeg_process.wait(timeout=0.1)
91
168
  except Exception:
92
- if self._ffmpeg_process: self._ffmpeg_process.kill()
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
- logging.info("FFmpeg process stopped.")
177
+ logger.info(f"FFmpeg process stopped for {self.pipeline_id}.")
95
178
 
96
- self._backend = None
97
-
98
- # -------------------- Internal Stream Management --------------------
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
- self._stop_evt.clear()
185
+ """
186
+ Starts the stream with a 2-stage FFmpeg fallback.
102
187
 
103
- gstreamer_pipeline = self._build_gstreamer_pipeline()
104
- self._gstreamer_writer = cv2.VideoWriter(gstreamer_pipeline, cv2.CAP_GSTREAMER, 0, self.fps, (self.width, self.height))
105
-
106
- if self._gstreamer_writer.isOpened():
107
- self._backend = "gstreamer"
108
- self._writer_thread = threading.Thread(target=self._gstreamer_writer_loop, daemon=True)
109
- self._writer_thread.start()
110
- logging.info(f"RTMP streaming started with GStreamer (HW-Accel): {self.rtmp_url}")
111
- return
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
- logging.warning("GStreamer VideoWriter failed to open. Falling back to FFmpeg subprocess.")
114
- self._gstreamer_writer = None
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
- cmd = self._build_ffmpeg_cmd()
117
- try:
118
- self._ffmpeg_process = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
119
- self._backend = "ffmpeg"
120
- self._writer_thread = threading.Thread(target=self._ffmpeg_pacing_loop, daemon=True)
121
- self._writer_thread.start()
122
- logging.info(f"RTMP streaming started with FFmpeg (Fallback): {self.rtmp_url}")
123
- except Exception as e:
124
- logging.error(f"Failed to start FFmpeg fallback: {e}")
125
- self._ffmpeg_process = None
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
- def _gstreamer_writer_loop(self):
128
- while not self._stop_evt.is_set():
129
- try:
130
- frame = self._frame_queue.get(timeout=1.0)
131
- if frame is None:
132
- break
133
- self._gstreamer_writer.write(frame)
134
- except queue.Empty:
135
- continue
136
- except Exception as e:
137
- logging.error(f"Error in GStreamer writer loop: {e}. Stopping thread.")
138
- self._stop_evt.set()
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
- # -------------------- Pipeline Builders --------------------
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
- def _build_gstreamer_pipeline(self) -> str:
144
- if self._platform.is_jetson():
145
- encoder = "nvv4l2h264enc insert-sps-pps=true"
146
- converter = "nvvidconv ! video/x-raw(memory:NVMM) ! "
147
- else:
148
- encoder = "nvh264enc preset=low-latency-hq"
149
- converter = "videoconvert"
150
-
151
- pipeline = (
152
- f"appsrc ! "
153
- f"video/x-raw,format=BGR,width={self.width},height={self.height},framerate={self.fps}/1 ! "
154
- f"{converter} ! {encoder} bitrate={self.bitrate} ! "
155
- f"h264parse ! flvmux ! "
156
- f"rtmpsink location=\"{self.rtmp_url}\""
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
- return pipeline
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
- logging.error(f"FFmpeg process pipe broken: {e}. Stopping thread.")
185
- self._stop_evt.set()
186
- break
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 _build_ffmpeg_cmd(self) -> list[str]:
193
- encoder_args = self._select_ffmpeg_encoder()
194
- encoder_name = encoder_args[1]
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) -> list:
226
- if sys.platform == "darwin": return ["-c:v", "h264_videotoolbox"]
227
- if os.environ.get("NVIDIA_VISIBLE_DEVICES") is not None or os.path.exists("/proc/driver/nvidia/version"):
228
- return ["-c:v", "h264_nvenc", "-preset", "llhp"]
229
- return ["-c:v", "libx264"]
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
- source.status_code
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️⃣ Add new streams**
42
- for source_id, (url, status_code) in db_sources.items():
43
- if source_id not in active_stream_ids and status_code == "connected":
44
- logging.info(f"🟢 Adding new stream: {source_id} ({url})")
45
- self.manager.add_stream(source_id, url)
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️⃣ Remove deleted streams**
48
- for stream_id in active_stream_ids:
49
- if stream_id not in db_sources or db_sources[stream_id][1] != "connected":
50
- logging.info(f"🔴 Removing deleted stream: {stream_id}")
51
- self.manager.remove_stream(stream_id)
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
- active_stream_ids = set(self.manager.get_active_stream_ids())
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, (url, status_code) in db_sources.items():
57
- if source_id in active_stream_ids:
58
- existing_url = self.manager.get_stream_url(source_id)
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
- logging.info(f"🟡 Updating stream {source_id}: New URL {url}")
61
- self.manager.remove_stream(source_id)
62
- # Add a small delay for device cleanup
63
- if self._is_direct_device(url) or self._is_direct_device(existing_url):
64
- time.sleep(0.5) # Allow device to be properly released
65
- self.manager.add_stream(source_id, url)
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}")