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

@@ -1,146 +1,284 @@
1
1
  import subprocess
2
2
  import logging
3
- import cv2
3
+ import threading
4
+ import time
4
5
  import numpy as np
5
6
  import os
7
+ from typing import Optional
6
8
 
7
9
  class RTMPStreamer:
8
- """Handles streaming video frames to an RTMP server using FFmpeg."""
10
+ """
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
+ """
9
16
 
10
- def __init__(self, pipeline_id, fps=25, bitrate="1500k"):
11
- """
12
- Initializes the RTMP streaming process.
17
+ def __init__(self, pipeline_id: str, fps: int = 25, bitrate: str = "1500k"):
18
+ self.rtmp_server = os.environ.get("RTMP_SERVER", "rtmp://localhost:1935/live")
19
+ self.rtmp_url = f"{self.rtmp_server}/{pipeline_id}"
20
+ 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")
26
+
27
+ self.width: Optional[int] = None
28
+ self.height: Optional[int] = None
29
+ self.ffmpeg_process: Optional[subprocess.Popen] = None
30
+ self.started = False
31
+ self.active = False
32
+
33
+ # Writer thread & latest-only buffer
34
+ self._writer_thread: Optional[threading.Thread] = None
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
+
39
+ # pacing
40
+ self._frame_period = 1.0 / self.fps
41
+
42
+ # -------------------- public API --------------------
13
43
 
14
- :param pipeline_id: Unique identifier for the stream (used as the stream key).
15
- :param fps: Frames per second.
16
- :param bitrate: Bitrate for video encoding.
44
+ def is_active(self) -> bool:
45
+ return bool(self.active and self.ffmpeg_process and self.ffmpeg_process.poll() is None)
46
+
47
+ def push_frame(self, frame: np.ndarray):
17
48
  """
18
- self.rtmp_server = os.environ.get("RTMP_SERVER", "rtmp://localhost:1935/live")
19
- self.rtmp_url = f"{self.rtmp_server}/{pipeline_id}" # RTMP URL with dynamic stream key
20
- self.fps = fps
21
- self.bitrate = bitrate
22
- self.width = None
23
- self.height = None
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:
55
+ return
56
+
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()
64
+
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():
79
+ 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
84
+
85
+ # tear down ffmpeg
86
+ proc = self.ffmpeg_process
24
87
  self.ffmpeg_process = None
25
- self.started = False # Ensure FFmpeg starts only once
26
- self.active = False # Add status flag
27
-
28
- def _calculate_resolution(self, frame):
29
- """Determines resolution with max width 1024 while maintaining aspect ratio."""
30
- original_height, original_width = frame.shape[:2]
31
- if original_width > 1024:
32
- scale_factor = 1024 / original_width
33
- new_width = 1024
34
- new_height = int(original_height * scale_factor)
35
- else:
36
- new_width, new_height = original_width, original_height
37
-
38
- logging.info(f"📏 Adjusted resolution: {new_width}x{new_height} (Original: {original_width}x{original_height})")
39
- return new_width, new_height
40
-
41
- def is_active(self):
42
- """Check if the RTMP streamer is active and ready to send frames."""
43
- return self.active and self.ffmpeg_process and self.ffmpeg_process.poll() is None
44
-
45
- def _start_ffmpeg_stream(self):
46
- """Starts an FFmpeg process to stream frames to the RTMP server silently."""
47
- ffmpeg_command = [
88
+ if not proc:
89
+ logging.info("RTMP streaming process already stopped.")
90
+ return
91
+
92
+ 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}")
106
+ try:
107
+ proc.kill()
108
+ 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 [
48
123
  "ffmpeg",
49
124
  "-y",
50
- "-loglevel", "panic", # 🔇 Suppress all output except fatal errors
51
- "-nostats", # 🔇 Hide encoding progress updates
52
- "-hide_banner", # 🔇 Hide FFmpeg banner information
125
+ "-loglevel", os.environ.get("FFLOG", "error"),
126
+ "-nostats",
127
+ "-hide_banner",
128
+
129
+ # raw frames via stdin
53
130
  "-f", "rawvideo",
54
131
  "-pixel_format", "bgr24",
55
132
  "-video_size", f"{self.width}x{self.height}",
56
133
  "-framerate", str(self.fps),
57
134
  "-i", "-",
135
+
136
+ # encoder
58
137
  "-c:v", "libx264",
59
- "-preset", "ultrafast",
60
138
  "-tune", "zerolatency",
139
+ "-preset", "ultrafast",
140
+ "-profile:v", "main",
141
+ "-pix_fmt", "yuv420p",
142
+
143
+ # CBR-like VBV
61
144
  "-b:v", self.bitrate,
62
- # ❌ Disable Audio (Avoid unnecessary encoding overhead)
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
63
160
  "-an",
64
- "-maxrate", "2000k",
65
- "-bufsize", "4000k",
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
+
66
169
  "-f", "flv",
67
170
  self.rtmp_url,
68
171
  ]
69
172
 
173
+ def _start_ffmpeg_and_writer(self):
174
+ """Start ffmpeg process and the pacing writer thread."""
175
+ cmd = self._build_ffmpeg_cmd()
70
176
  try:
71
177
  with open(os.devnull, "w") as devnull:
72
178
  self.ffmpeg_process = subprocess.Popen(
73
- ffmpeg_command,
179
+ cmd,
74
180
  stdin=subprocess.PIPE,
75
181
  stdout=devnull,
76
- stderr=devnull
182
+ stderr=devnull if os.environ.get("FFSILENT", "1") == "1" else None,
183
+ bufsize=0, # unbuffered for low latency
77
184
  )
78
- logging.info(f"📡 RTMP streaming started: {self.rtmp_url} ({self.width}x{self.height})")
79
185
  self.started = True
80
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
+ )
192
+ self._writer_thread.start()
193
+ logging.info(f"RTMP streaming started: {self.rtmp_url} ({self.width}x{self.height}@{self.fps}fps)")
81
194
  except Exception as e:
82
- logging.error(f"Failed to start FFmpeg: {e}")
195
+ logging.error(f"Failed to start FFmpeg: {e}")
83
196
  self.ffmpeg_process = None
84
197
  self.active = False
198
+ self.started = False
85
199
 
86
- def send_frame(self, frame):
87
- """Sends a video frame to the RTMP stream with dynamic resolution."""
88
- if frame is None or not isinstance(frame, np.ndarray):
89
- logging.error("❌ Invalid frame received")
90
- return
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
91
204
 
92
- try:
93
- # Validate frame before processing
94
- if frame.size == 0 or not frame.data:
95
- logging.error("❌ Empty frame detected")
96
- return
205
+ while not self._stop_evt.is_set():
206
+ 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
97
215
 
98
- # Set resolution on the first frame
99
- if not self.started:
100
- self.width, self.height = self._calculate_resolution(frame)
101
- self._start_ffmpeg_stream()
102
-
103
- if self.is_active():
104
- # Create a copy of the frame to prevent reference issues
105
- frame_copy = frame.copy()
106
-
107
- # Resize only if necessary
108
- if frame_copy.shape[1] > 1024:
109
- frame_copy = cv2.resize(frame_copy, (self.width, self.height),
110
- interpolation=cv2.INTER_AREA)
111
-
112
- # Additional frame validation
113
- if frame_copy.size == 0 or not frame_copy.data:
114
- logging.error("❌ Frame became invalid after processing")
115
- return
116
-
117
- if self.ffmpeg_process and self.ffmpeg_process.stdin:
118
- self.ffmpeg_process.stdin.write(frame_copy.tobytes())
119
- self.ffmpeg_process.stdin.flush() # Ensure data is written
120
-
121
- except BrokenPipeError:
122
- logging.error("❌ RTMP connection broken")
123
- self.stop_stream()
124
- except Exception as e:
125
- logging.error(f"❌ Failed to send frame to RTMP: {e}")
126
- self.stop_stream()
216
+ # get the freshest frame
217
+ with self._lock:
218
+ frame = self._latest_frame
127
219
 
128
- def stop_stream(self):
129
- """Stops the FFmpeg streaming process."""
220
+ 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
239
+ 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()
244
+
245
+ def _restart_ffmpeg(self):
246
+ """Restart ffmpeg while keeping width/height and thread alive."""
130
247
  self.active = False
131
- if self.ffmpeg_process:
248
+ proc = self.ffmpeg_process
249
+ self.ffmpeg_process = None
250
+ if proc:
132
251
  try:
133
- if self.ffmpeg_process.stdin:
134
- self.ffmpeg_process.stdin.close()
135
- self.ffmpeg_process.terminate()
136
- self.ffmpeg_process.wait(timeout=5)
137
- except Exception as e:
138
- logging.error(f"❌ Error stopping RTMP stream: {e}")
139
- # Force kill if normal termination fails
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:
140
264
  try:
141
- self.ffmpeg_process.kill()
265
+ proc.kill()
142
266
  except Exception:
143
267
  pass
144
- finally:
145
- self.ffmpeg_process = None
146
- logging.info("✅ RTMP streaming process stopped.")
268
+
269
+ # restart only if we have size info
270
+ if self.width and self.height:
271
+ 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