nedo-vision-worker-core 0.3.3__py3-none-any.whl → 0.3.5__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.

@@ -4,281 +4,226 @@ import threading
4
4
  import time
5
5
  import numpy as np
6
6
  import os
7
+ import sys
8
+ import cv2
9
+ import queue
7
10
  from typing import Optional
11
+ from ..util.PlatformDetector import PlatformDetector
8
12
 
9
13
  class RTMPStreamer:
10
14
  """
11
- Streams raw BGR frames to FFmpeg -> RTMP with:
12
- - Internal pacing thread (exact fps)
13
- - Latest-only frame buffer (no backlog)
14
- - Resilient auto-restart on failure
15
+ Streams raw BGR frames to an RTMP server using a GStreamer-first approach
16
+ with a reliable FFmpeg subprocess fallback.
15
17
  """
16
18
 
17
19
  def __init__(self, pipeline_id: str, fps: int = 25, bitrate: str = "1500k"):
18
20
  self.rtmp_server = os.environ.get("RTMP_SERVER", "rtmp://localhost:1935/live")
19
21
  self.rtmp_url = f"{self.rtmp_server}/{pipeline_id}"
20
22
  self.fps = max(int(fps), 1)
21
- self.bitrate = bitrate # e.g. "1500k"
22
-
23
- # VBV aligned to target bitrate by default (override via env if needed)
24
- self.maxrate = os.environ.get("RTMP_MAXRATE", self.bitrate)
25
- self.bufsize = os.environ.get("RTMP_BUFSIZE", f"{self._kbps(self.bitrate) * 2}k")
23
+ self.bitrate = self._kbps(bitrate) # Store as integer kbps
26
24
 
27
25
  self.width: Optional[int] = None
28
26
  self.height: Optional[int] = None
29
- self.ffmpeg_process: Optional[subprocess.Popen] = None
30
- self.started = False
31
- self.active = False
27
+ self._platform = PlatformDetector()
28
+
29
+ self._backend = None # "gstreamer" or "ffmpeg"
30
+ self._gstreamer_writer: Optional[cv2.VideoWriter] = None
31
+ self._ffmpeg_process: Optional[subprocess.Popen] = None
32
32
 
33
- # Writer thread & latest-only buffer
33
+ self._frame_queue = queue.Queue(maxsize=2)
34
34
  self._writer_thread: Optional[threading.Thread] = None
35
35
  self._stop_evt = threading.Event()
36
- self._lock = threading.Lock()
37
- self._latest_frame: Optional[np.ndarray] = None # last BGR frame set by push_frame()
38
36
 
39
- # pacing
40
- self._frame_period = 1.0 / self.fps
37
+ def _kbps(self, rate_str: str) -> int:
38
+ return int(str(rate_str).lower().replace("k", "").strip())
41
39
 
42
- # -------------------- public API --------------------
40
+ # -------------------- Public API --------------------
43
41
 
44
42
  def is_active(self) -> bool:
45
- return bool(self.active and self.ffmpeg_process and self.ffmpeg_process.poll() is None)
43
+ if self._backend == "gstreamer":
44
+ return self._gstreamer_writer is not None and self._gstreamer_writer.isOpened()
45
+ if self._backend == "ffmpeg":
46
+ return self._ffmpeg_process is not None and self._ffmpeg_process.poll() is None
47
+ return False
46
48
 
47
49
  def push_frame(self, frame: np.ndarray):
48
- """
49
- Provide a frame to the streamer. The internal writer thread will pick up
50
- the latest frame at the correct fps. Older frames are dropped.
51
- """
52
- if frame is None or getattr(frame, "size", 0) == 0:
53
- return
54
- if frame.ndim != 3 or frame.shape[2] != 3:
50
+ if self._stop_evt.is_set():
55
51
  return
56
52
 
57
- # On the very first frame, learn WxH and start ffmpeg + writer
58
- if not self.started:
59
- h, w = frame.shape[:2]
60
- if w <= 0 or h <= 0:
61
- return
62
- self.width, self.height = w, h
63
- self._start_ffmpeg_and_writer()
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()
64
57
 
65
- # Store latest only
66
- with self._lock:
67
- if frame.dtype != np.uint8:
68
- frame = frame.astype(np.uint8, copy=False)
69
- if not frame.flags['C_CONTIGUOUS']:
70
- frame = np.ascontiguousarray(frame)
71
- self._latest_frame = frame
72
-
73
- def stop_stream(self):
74
- """Stop writer thread and FFmpeg cleanly."""
75
- self.active = False
76
- self._stop_evt.set()
77
-
78
- if self._writer_thread and self._writer_thread.is_alive():
58
+ try:
59
+ self._frame_queue.put_nowait(frame)
60
+ except queue.Full:
79
61
  try:
80
- self._writer_thread.join(timeout=2.0)
81
- except Exception as e:
82
- logging.error(f"RTMP writer thread join error: {e}")
83
- self._writer_thread = None
62
+ self._frame_queue.get_nowait()
63
+ self._frame_queue.put_nowait(frame)
64
+ except queue.Empty:
65
+ pass
84
66
 
85
- # tear down ffmpeg
86
- proc = self.ffmpeg_process
87
- self.ffmpeg_process = None
88
- if not proc:
89
- logging.info("RTMP streaming process already stopped.")
67
+ def stop_stream(self):
68
+ if self._stop_evt.is_set():
90
69
  return
91
-
70
+ logging.info(f"Stopping RTMP stream for {self.rtmp_url}")
71
+ self._stop_evt.set()
72
+
92
73
  try:
93
- if proc.stdin:
94
- try:
95
- proc.stdin.flush()
96
- except Exception:
97
- pass
98
- try:
99
- proc.stdin.close()
100
- except Exception:
101
- pass
102
- proc.terminate()
103
- proc.wait(timeout=5)
104
- except Exception as e:
105
- logging.error(f"Error stopping RTMP stream: {e}")
74
+ self._frame_queue.put_nowait(None)
75
+ except queue.Full:
76
+ pass
77
+
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)
80
+
81
+ if self._gstreamer_writer:
82
+ self._gstreamer_writer.release()
83
+ self._gstreamer_writer = None
84
+ logging.info("GStreamer writer released.")
85
+
86
+ if self._ffmpeg_process:
106
87
  try:
107
- proc.kill()
88
+ if self._ffmpeg_process.stdin: self._ffmpeg_process.stdin.close()
89
+ self._ffmpeg_process.terminate()
90
+ self._ffmpeg_process.wait(timeout=2.0)
108
91
  except Exception:
109
- pass
110
- finally:
111
- logging.info("RTMP streaming process stopped.")
112
- self.started = False
113
-
114
- # -------------------- internals --------------------
115
-
116
- def _kbps(self, rate_str: str) -> int:
117
- return int(str(rate_str).lower().replace("k", "").strip())
118
-
119
- def _build_ffmpeg_cmd(self) -> list[str]:
120
- # GOP = 1 second for faster recovery; disable scene-cut; repeat headers
121
- gop = max(self.fps, 1)
122
- return [
123
- "ffmpeg",
124
- "-y",
125
- "-loglevel", os.environ.get("FFLOG", "error"),
126
- "-nostats",
127
- "-hide_banner",
128
-
129
- # raw frames via stdin
130
- "-f", "rawvideo",
131
- "-pixel_format", "bgr24",
132
- "-video_size", f"{self.width}x{self.height}",
133
- "-framerate", str(self.fps),
134
- "-i", "-",
135
-
136
- # encoder
137
- "-c:v", "libx264",
138
- "-tune", "zerolatency",
139
- "-preset", "ultrafast",
140
- "-profile:v", "main",
141
- "-pix_fmt", "yuv420p",
142
-
143
- # CBR-like VBV
144
- "-b:v", self.bitrate,
145
- "-maxrate", self.maxrate,
146
- "-bufsize", self.bufsize,
147
-
148
- # GOP / keyframes
149
- "-g", str(gop),
150
- "-keyint_min", str(gop),
151
- "-sc_threshold", "0",
152
- "-x264-params", "open_gop=0:aud=1:repeat-headers=1:nal-hrd=cbr",
153
- # force an IDR every 1s for faster player join/recovery
154
- "-force_key_frames", "expr:gte(t,n_forced*1)",
155
-
156
- # single scaling location (if needed downstream)
157
- "-vf", "scale='min(1024,iw)':-2",
158
-
159
- # no audio
160
- "-an",
161
-
162
- # reduce container buffering
163
- "-flvflags", "no_duration_filesize",
164
- "-flush_packets", "1",
165
- "-rtmp_live", "live",
166
- "-muxpreload", "0",
167
- "-muxdelay", "0",
168
-
169
- "-f", "flv",
170
- self.rtmp_url,
171
- ]
92
+ if self._ffmpeg_process: self._ffmpeg_process.kill()
93
+ self._ffmpeg_process = None
94
+ logging.info("FFmpeg process stopped.")
95
+
96
+ self._backend = None
97
+
98
+ # -------------------- Internal Stream Management --------------------
99
+
100
+ def _start_stream(self):
101
+ self._stop_evt.clear()
102
+
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
112
+
113
+ logging.warning("GStreamer VideoWriter failed to open. Falling back to FFmpeg subprocess.")
114
+ self._gstreamer_writer = None
172
115
 
173
- def _start_ffmpeg_and_writer(self):
174
- """Start ffmpeg process and the pacing writer thread."""
175
116
  cmd = self._build_ffmpeg_cmd()
176
117
  try:
177
- with open(os.devnull, "w") as devnull:
178
- self.ffmpeg_process = subprocess.Popen(
179
- cmd,
180
- stdin=subprocess.PIPE,
181
- stdout=devnull,
182
- stderr=devnull if os.environ.get("FFSILENT", "1") == "1" else None,
183
- bufsize=0, # unbuffered for low latency
184
- )
185
- self.started = True
186
- self.active = True
187
- self._stop_evt.clear()
188
- self._writer_thread = threading.Thread(
189
- target=self._writer_loop, name=f"rtmp-writer-{os.getpid()}",
190
- daemon=True
191
- )
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)
192
121
  self._writer_thread.start()
193
- logging.info(f"RTMP streaming started: {self.rtmp_url} ({self.width}x{self.height}@{self.fps}fps)")
122
+ logging.info(f"RTMP streaming started with FFmpeg (Fallback): {self.rtmp_url}")
194
123
  except Exception as e:
195
- logging.error(f"Failed to start FFmpeg: {e}")
196
- self.ffmpeg_process = None
197
- self.active = False
198
- self.started = False
199
-
200
- def _writer_loop(self):
201
- """Paces frames at exact fps, writing latest frame only. Auto-restarts on failure."""
202
- next_deadline = time.monotonic() + self._frame_period
203
- idle_frame: Optional[np.ndarray] = None # reuse last good frame if no new one arrived
124
+ logging.error(f"Failed to start FFmpeg fallback: {e}")
125
+ self._ffmpeg_process = None
204
126
 
127
+ def _gstreamer_writer_loop(self):
205
128
  while not self._stop_evt.is_set():
206
129
  try:
207
- # pacing
208
- now = time.monotonic()
209
- sleep_for = next_deadline - now
210
- if sleep_for > 0:
211
- time.sleep(sleep_for)
212
- next_deadline += self._frame_period
213
- if next_deadline < now - self._frame_period:
214
- next_deadline = now + self._frame_period
215
-
216
- # get the freshest frame
217
- with self._lock:
218
- frame = self._latest_frame
219
-
130
+ frame = self._frame_queue.get(timeout=1.0)
220
131
  if frame is None:
221
- # no new frame—if we have an idle one, repeat it (prevents RTMP underflow)
222
- frame_to_send = idle_frame
223
- if frame_to_send is None:
224
- continue # nothing to send yet
225
- else:
226
- frame_to_send = frame
227
- idle_frame = frame
228
-
229
- if not self.is_active():
230
- raise BrokenPipeError("FFmpeg not active")
231
-
232
- # write raw BGR24
233
- self.ffmpeg_process.stdin.write(frame_to_send.tobytes())
234
-
235
- except BrokenPipeError:
236
- # logging.error("RTMP pipe broken. Restarting encoder.")
237
- self._restart_ffmpeg()
238
- # On restart we keep width/height; writer will continue
132
+ break
133
+ self._gstreamer_writer.write(frame)
134
+ except queue.Empty:
135
+ continue
239
136
  except Exception as e:
240
- # Any unexpected error: try a restart, but do not spin
241
- # logging.error(f"RTMP writer error: {e}")
242
- time.sleep(0.1)
243
- self._restart_ffmpeg()
137
+ logging.error(f"Error in GStreamer writer loop: {e}. Stopping thread.")
138
+ self._stop_evt.set()
139
+ break
140
+
141
+ # -------------------- Pipeline Builders --------------------
142
+
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}\""
157
+ )
158
+ return pipeline
159
+
160
+ # --- FFmpeg Fallback Methods ---
161
+
162
+ def _ffmpeg_pacing_loop(self):
163
+ frame_period = 1.0 / self.fps
164
+ last_frame_sent = None
244
165
 
245
- def _restart_ffmpeg(self):
246
- """Restart ffmpeg while keeping width/height and thread alive."""
247
- self.active = False
248
- proc = self.ffmpeg_process
249
- self.ffmpeg_process = None
250
- if proc:
166
+ while not self._stop_evt.is_set():
167
+ start_time = time.monotonic()
168
+
251
169
  try:
252
- if proc.stdin:
253
- try:
254
- proc.stdin.flush()
255
- except Exception:
256
- pass
257
- try:
258
- proc.stdin.close()
259
- except Exception:
260
- pass
261
- proc.terminate()
262
- proc.wait(timeout=3)
263
- except Exception:
264
- try:
265
- proc.kill()
266
- except Exception:
267
- pass
170
+ frame = self._frame_queue.get_nowait()
171
+ last_frame_sent = frame
172
+ except queue.Empty:
173
+ frame = last_frame_sent
174
+
175
+ if frame is None:
176
+ time.sleep(frame_period)
177
+ continue
268
178
 
269
- # restart only if we have size info
270
- if self.width and self.height:
271
179
  try:
272
- with open(os.devnull, "w") as devnull:
273
- self.ffmpeg_process = subprocess.Popen(
274
- self._build_ffmpeg_cmd(),
275
- stdin=subprocess.PIPE,
276
- stdout=devnull,
277
- stderr=devnull if os.environ.get("FFSILENT", "1") == "1" else None,
278
- bufsize=0,
279
- )
280
- self.active = True
281
- # logging.info("RTMP encoder restarted.")
282
- except Exception as e:
283
- logging.error(f"Failed to restart FFmpeg: {e}")
284
- self.active = False
180
+ if not self.is_active():
181
+ raise BrokenPipeError("FFmpeg process is not active")
182
+ self._ffmpeg_process.stdin.write(frame.tobytes())
183
+ except (BrokenPipeError, OSError) as e:
184
+ logging.error(f"FFmpeg process pipe broken: {e}. Stopping thread.")
185
+ self._stop_evt.set()
186
+ break
187
+
188
+ elapsed = time.monotonic() - start_time
189
+ sleep_duration = max(0, frame_period - elapsed)
190
+ time.sleep(sleep_duration)
191
+
192
+ def _build_ffmpeg_cmd(self) -> list[str]:
193
+ encoder_args = self._select_ffmpeg_encoder()
194
+ encoder_name = encoder_args[1]
195
+
196
+ # Base command arguments
197
+ cmd = [
198
+ 'ffmpeg', '-y', '-loglevel', 'error', '-nostats', '-hide_banner',
199
+ '-f', 'rawvideo', '-pixel_format', 'bgr24',
200
+ '-video_size', f'{self.width}x{self.height}',
201
+ '-framerate', str(self.fps), '-i', '-',
202
+ ]
203
+
204
+ # Add the selected encoder
205
+ cmd.extend(encoder_args)
206
+
207
+ # Add common arguments for all encoders
208
+ cmd.extend([
209
+ '-profile:v', 'main', '-pix_fmt', 'yuv420p',
210
+ '-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),
212
+ '-force_key_frames', 'expr:gte(t,n_forced*1)', '-an',
213
+ '-flvflags', 'no_duration_filesize', '-f', 'flv', self.rtmp_url,
214
+ ])
215
+
216
+ # Conditionally add arguments that are ONLY valid for the libx264 encoder
217
+ if encoder_name == "libx264":
218
+ cmd.extend([
219
+ "-tune", "zerolatency",
220
+ "-x264-params", "open_gop=0:aud=1:repeat-headers=1:nal-hrd=cbr",
221
+ ])
222
+
223
+ return cmd
224
+
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"]
@@ -8,6 +8,10 @@ from .VideoStream import VideoStream
8
8
  from ..services.SharedVideoStreamServer import get_shared_stream_server
9
9
  from ..services.VideoSharingDaemonManager import get_daemon_manager
10
10
 
11
+ import numpy as np
12
+ from numpy.typing import NDArray
13
+ MatLike = NDArray[np.uint8]
14
+
11
15
  try:
12
16
  from nedo_vision_worker_core.services.VideoSharingDaemon import VideoSharingClient
13
17
  except ImportError:
@@ -77,7 +81,7 @@ class SharedVideoDeviceManager:
77
81
  pass
78
82
  return False, None
79
83
 
80
- def subscribe_to_device(self, source, subscriber_id: str, callback: Callable[[cv2.Mat], None]) -> bool:
84
+ def subscribe_to_device(self, source, subscriber_id: str, callback: Callable[[MatLike], None]) -> bool:
81
85
  """
82
86
  Subscribe to a direct video device.
83
87
 
@@ -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}")