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.
- nedo_vision_worker_core/__init__.py +1 -1
- nedo_vision_worker_core/doctor.py +1 -1
- nedo_vision_worker_core/pipeline/PipelineProcessor.py +116 -185
- nedo_vision_worker_core/streams/RTMPStreamer.py +239 -101
- nedo_vision_worker_core/streams/VideoStream.py +279 -202
- nedo_vision_worker_core/streams/VideoStreamManager.py +240 -211
- {nedo_vision_worker_core-0.3.1.dist-info → nedo_vision_worker_core-0.3.3.dist-info}/METADATA +2 -35
- {nedo_vision_worker_core-0.3.1.dist-info → nedo_vision_worker_core-0.3.3.dist-info}/RECORD +11 -11
- {nedo_vision_worker_core-0.3.1.dist-info → nedo_vision_worker_core-0.3.3.dist-info}/WHEEL +0 -0
- {nedo_vision_worker_core-0.3.1.dist-info → nedo_vision_worker_core-0.3.3.dist-info}/entry_points.txt +0 -0
- {nedo_vision_worker_core-0.3.1.dist-info → nedo_vision_worker_core-0.3.3.dist-info}/top_level.txt +0 -0
|
@@ -1,146 +1,284 @@
|
|
|
1
1
|
import subprocess
|
|
2
2
|
import logging
|
|
3
|
-
import
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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", "
|
|
51
|
-
"-nostats",
|
|
52
|
-
"-hide_banner",
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
|
87
|
-
"""
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
248
|
+
proc = self.ffmpeg_process
|
|
249
|
+
self.ffmpeg_process = None
|
|
250
|
+
if proc:
|
|
132
251
|
try:
|
|
133
|
-
if
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
265
|
+
proc.kill()
|
|
142
266
|
except Exception:
|
|
143
267
|
pass
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|