nedo-vision-worker 1.1.3__py3-none-any.whl → 1.2.1__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.
- nedo_vision_worker/__init__.py +1 -1
- nedo_vision_worker/cli.py +196 -167
- nedo_vision_worker/database/DatabaseManager.py +3 -3
- nedo_vision_worker/doctor.py +1066 -386
- nedo_vision_worker/models/ai_model.py +35 -2
- nedo_vision_worker/protos/AIModelService_pb2.py +12 -10
- nedo_vision_worker/protos/AIModelService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/DatasetSourceService_pb2.py +2 -2
- nedo_vision_worker/protos/DatasetSourceService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/HumanDetectionService_pb2.py +2 -2
- nedo_vision_worker/protos/HumanDetectionService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/PPEDetectionService_pb2.py +2 -2
- nedo_vision_worker/protos/PPEDetectionService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/VisionWorkerService_pb2.py +2 -2
- nedo_vision_worker/protos/VisionWorkerService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/WorkerSourcePipelineService_pb2.py +2 -2
- nedo_vision_worker/protos/WorkerSourcePipelineService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/WorkerSourceService_pb2.py +2 -2
- nedo_vision_worker/protos/WorkerSourceService_pb2_grpc.py +1 -1
- nedo_vision_worker/services/AIModelClient.py +184 -160
- nedo_vision_worker/services/DirectDeviceToRTMPStreamer.py +534 -0
- nedo_vision_worker/services/GrpcClientBase.py +142 -108
- nedo_vision_worker/services/PPEDetectionClient.py +0 -7
- nedo_vision_worker/services/RestrictedAreaClient.py +0 -5
- nedo_vision_worker/services/SharedDirectDeviceClient.py +278 -0
- nedo_vision_worker/services/SharedVideoStreamServer.py +315 -0
- nedo_vision_worker/services/SystemWideDeviceCoordinator.py +236 -0
- nedo_vision_worker/services/VideoSharingDaemon.py +832 -0
- nedo_vision_worker/services/VideoStreamClient.py +43 -20
- nedo_vision_worker/services/WorkerSourceClient.py +1 -1
- nedo_vision_worker/services/WorkerSourcePipelineClient.py +35 -12
- nedo_vision_worker/services/WorkerSourceUpdater.py +30 -3
- nedo_vision_worker/util/FFmpegUtil.py +124 -0
- nedo_vision_worker/util/VideoProbeUtil.py +227 -17
- nedo_vision_worker/worker/DataSyncWorker.py +1 -0
- nedo_vision_worker/worker/PipelineImageWorker.py +1 -1
- nedo_vision_worker/worker/VideoStreamWorker.py +27 -3
- nedo_vision_worker/worker/WorkerManager.py +2 -29
- nedo_vision_worker/worker_service.py +22 -5
- {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.1.dist-info}/METADATA +1 -3
- {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.1.dist-info}/RECORD +44 -38
- {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.1.dist-info}/WHEEL +0 -0
- {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.1.dist-info}/entry_points.txt +0 -0
- {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
import platform
|
|
5
|
+
import threading
|
|
6
|
+
import numpy as np
|
|
7
|
+
import cv2
|
|
8
|
+
import os
|
|
9
|
+
from .VideoSharingDaemon import VideoSharingClient
|
|
10
|
+
from ..database.DatabaseManager import get_storage_path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DirectDeviceToRTMPStreamer:
|
|
14
|
+
def __init__(self, device_url: str, rtmp_server: str, stream_key: str, stream_duration: int):
|
|
15
|
+
"""
|
|
16
|
+
Initialize the DirectDeviceToRTMPStreamer.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
device_url: Camera device URL or index (as string)
|
|
20
|
+
rtmp_server: RTMP server base URL
|
|
21
|
+
stream_key: Stream key/UUID
|
|
22
|
+
stream_duration: Duration in seconds
|
|
23
|
+
"""
|
|
24
|
+
self.device_url = device_url
|
|
25
|
+
self.rtmp_server = rtmp_server
|
|
26
|
+
self.stream_key = stream_key
|
|
27
|
+
self.duration = stream_duration
|
|
28
|
+
|
|
29
|
+
# Parse device index from URL
|
|
30
|
+
is_device, device_index = self._is_direct_device(device_url)
|
|
31
|
+
if not is_device:
|
|
32
|
+
raise ValueError(f"Invalid device URL: {device_url}")
|
|
33
|
+
|
|
34
|
+
self.device_index = device_index
|
|
35
|
+
self.rtmp_url = f"{rtmp_server}/{stream_key}"
|
|
36
|
+
|
|
37
|
+
self.ffmpeg_process = None
|
|
38
|
+
self.active = False
|
|
39
|
+
self.stop_event = threading.Event()
|
|
40
|
+
|
|
41
|
+
# Streaming state variables
|
|
42
|
+
self.started = False
|
|
43
|
+
self.start_time = None
|
|
44
|
+
self.width = None
|
|
45
|
+
self.height = None
|
|
46
|
+
self.fps = 30 # Default FPS
|
|
47
|
+
self.bitrate = "2000k" # Default bitrate
|
|
48
|
+
|
|
49
|
+
# Video sharing components
|
|
50
|
+
self.video_client = None
|
|
51
|
+
|
|
52
|
+
def _calculate_resolution(self, frame):
|
|
53
|
+
"""Determines resolution with max width 1024 while maintaining aspect ratio."""
|
|
54
|
+
original_height, original_width = frame.shape[:2]
|
|
55
|
+
if original_width > 1024:
|
|
56
|
+
scale_factor = 1024 / original_width
|
|
57
|
+
new_width = 1024
|
|
58
|
+
new_height = int(original_height * scale_factor)
|
|
59
|
+
else:
|
|
60
|
+
new_width, new_height = original_width, original_height
|
|
61
|
+
|
|
62
|
+
logging.info(f"📏 [APP] Adjusted resolution: {new_width}x{new_height} (Original: {original_width}x{original_height})")
|
|
63
|
+
return new_width, new_height
|
|
64
|
+
|
|
65
|
+
def is_active(self):
|
|
66
|
+
"""Check if the RTMP streamer is active and ready to send frames."""
|
|
67
|
+
return self.active and self.ffmpeg_process and self.ffmpeg_process.poll() is None
|
|
68
|
+
|
|
69
|
+
def _start_ffmpeg_stream(self):
|
|
70
|
+
"""Starts an FFmpeg process to stream frames to the RTMP server silently."""
|
|
71
|
+
ffmpeg_command = [
|
|
72
|
+
"ffmpeg",
|
|
73
|
+
"-y",
|
|
74
|
+
"-loglevel", "warning", # Show warnings and errors
|
|
75
|
+
"-nostats", # Hide encoding progress updates
|
|
76
|
+
"-hide_banner", # Hide FFmpeg banner information
|
|
77
|
+
"-f", "rawvideo",
|
|
78
|
+
"-pixel_format", "bgr24",
|
|
79
|
+
"-video_size", f"{self.width}x{self.height}",
|
|
80
|
+
"-framerate", str(self.fps),
|
|
81
|
+
"-i", "-",
|
|
82
|
+
"-c:v", "libx264",
|
|
83
|
+
"-preset", "ultrafast",
|
|
84
|
+
"-tune", "zerolatency",
|
|
85
|
+
"-b:v", self.bitrate,
|
|
86
|
+
# Disable Audio (Avoid unnecessary encoding overhead)
|
|
87
|
+
"-an",
|
|
88
|
+
"-maxrate", "2500k",
|
|
89
|
+
"-bufsize", "5000k",
|
|
90
|
+
"-f", "flv",
|
|
91
|
+
# Remove duration limit - let application control duration
|
|
92
|
+
self.rtmp_url,
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
with open(os.devnull, "w") as devnull:
|
|
97
|
+
self.ffmpeg_process = subprocess.Popen(
|
|
98
|
+
ffmpeg_command,
|
|
99
|
+
stdin=subprocess.PIPE,
|
|
100
|
+
stdout=devnull,
|
|
101
|
+
stderr=subprocess.PIPE # Capture stderr for error monitoring
|
|
102
|
+
)
|
|
103
|
+
logging.info(f"📡 [APP] RTMP streaming started: {self.rtmp_url} ({self.width}x{self.height})")
|
|
104
|
+
self.started = True
|
|
105
|
+
self.active = True
|
|
106
|
+
|
|
107
|
+
# Start error monitoring thread
|
|
108
|
+
error_thread = threading.Thread(target=self._monitor_ffmpeg_stderr, daemon=True)
|
|
109
|
+
error_thread.start()
|
|
110
|
+
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logging.error(f"❌ [APP] Failed to start FFmpeg: {e}")
|
|
115
|
+
self.ffmpeg_process = None
|
|
116
|
+
self.active = False
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
def send_frame(self, frame):
|
|
120
|
+
"""Sends a video frame to the RTMP stream with dynamic resolution."""
|
|
121
|
+
if frame is None or not isinstance(frame, np.ndarray):
|
|
122
|
+
logging.error("❌ [APP] Invalid frame received")
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
# Check if duration has been exceeded
|
|
127
|
+
if self.start_time and time.time() - self.start_time > self.duration:
|
|
128
|
+
logging.info("🕒 [APP] Stream duration reached, stopping")
|
|
129
|
+
self.stop_stream()
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
# Validate frame before processing
|
|
133
|
+
if frame.size == 0 or not frame.data:
|
|
134
|
+
logging.error("❌ [APP] Empty frame detected")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
# Set resolution on the first frame
|
|
138
|
+
if not self.started:
|
|
139
|
+
self.width, self.height = self._calculate_resolution(frame)
|
|
140
|
+
if not self._start_ffmpeg_stream():
|
|
141
|
+
logging.error("❌ [APP] Failed to start FFmpeg stream")
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
if self.is_active():
|
|
145
|
+
# Create a copy of the frame to prevent reference issues
|
|
146
|
+
frame_copy = frame.copy()
|
|
147
|
+
|
|
148
|
+
# Resize only if necessary
|
|
149
|
+
if frame_copy.shape[1] > 1024:
|
|
150
|
+
frame_copy = cv2.resize(frame_copy, (self.width, self.height),
|
|
151
|
+
interpolation=cv2.INTER_AREA)
|
|
152
|
+
|
|
153
|
+
# Additional frame validation
|
|
154
|
+
if frame_copy.size == 0 or not frame_copy.data:
|
|
155
|
+
logging.error("❌ [APP] Frame became invalid after processing")
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
if self.ffmpeg_process and self.ffmpeg_process.stdin:
|
|
159
|
+
self.ffmpeg_process.stdin.write(frame_copy.tobytes())
|
|
160
|
+
# Don't flush - let FFmpeg handle buffering
|
|
161
|
+
|
|
162
|
+
except BrokenPipeError:
|
|
163
|
+
logging.error("❌ [APP] RTMP connection broken")
|
|
164
|
+
self.stop_stream()
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logging.error(f"❌ [APP] Failed to send frame to RTMP: {e}")
|
|
167
|
+
self.stop_stream()
|
|
168
|
+
|
|
169
|
+
def _monitor_ffmpeg_stderr(self):
|
|
170
|
+
"""Monitor FFmpeg stderr for errors and important messages."""
|
|
171
|
+
if not self.ffmpeg_process or not self.ffmpeg_process.stderr:
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
while self.ffmpeg_process.poll() is None and not self.stop_event.is_set():
|
|
176
|
+
line = self.ffmpeg_process.stderr.readline()
|
|
177
|
+
if line:
|
|
178
|
+
line_str = line.decode('utf-8', errors='ignore').strip()
|
|
179
|
+
if line_str:
|
|
180
|
+
# Filter important messages
|
|
181
|
+
if any(keyword in line_str.lower() for keyword in ['error', 'failed', 'invalid', 'could not']):
|
|
182
|
+
logging.error(f"🚨 [FFmpeg] {line_str}")
|
|
183
|
+
elif any(keyword in line_str.lower() for keyword in ['warning', 'deprecated']):
|
|
184
|
+
logging.warning(f"⚠️ [FFmpeg] {line_str}")
|
|
185
|
+
else:
|
|
186
|
+
logging.debug(f"ℹ️ [FFmpeg] {line_str}")
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logging.warning(f"Error monitoring FFmpeg stderr: {e}")
|
|
189
|
+
|
|
190
|
+
def _is_direct_device(self, url) -> bool:
|
|
191
|
+
"""Check if the URL is a direct video device."""
|
|
192
|
+
try:
|
|
193
|
+
device_index = int(url)
|
|
194
|
+
return True, device_index
|
|
195
|
+
except ValueError:
|
|
196
|
+
return False, None
|
|
197
|
+
|
|
198
|
+
def start_stream(self):
|
|
199
|
+
"""Start streaming direct device to RTMP using cross-process video sharing with fallbacks."""
|
|
200
|
+
is_device, device_index = self._is_direct_device(self.device_url)
|
|
201
|
+
if not is_device:
|
|
202
|
+
logging.error(f"❌ [APP] Invalid direct device URL: {self.device_url}")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
self.device_index = device_index
|
|
206
|
+
|
|
207
|
+
logging.info(f"🔄 [APP] Attempting video sharing daemon for device {self.device_index}")
|
|
208
|
+
if self._try_video_sharing_daemon():
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
# Fallback 1: Direct OpenCV streaming
|
|
212
|
+
logging.info(f"🔄 [APP] Video sharing daemon not available for device {self.device_index}, falling back to direct OpenCV streaming")
|
|
213
|
+
if self._start_direct_opencv_streaming():
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
# Fallback 2: Direct FFmpeg streaming
|
|
217
|
+
logging.info(f"🔄 [APP] OpenCV streaming failed for device {self.device_index}, trying direct FFmpeg streaming")
|
|
218
|
+
if self._start_direct_ffmpeg_streaming():
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
logging.error(f"❌ [APP] All streaming methods failed for device {self.device_index}")
|
|
222
|
+
|
|
223
|
+
def _try_video_sharing_daemon(self):
|
|
224
|
+
"""Try to start streaming using video sharing daemon with timeout."""
|
|
225
|
+
try:
|
|
226
|
+
# Get storage path from DatabaseManager
|
|
227
|
+
storage_path = None
|
|
228
|
+
if get_storage_path:
|
|
229
|
+
try:
|
|
230
|
+
storage_path = str(get_storage_path())
|
|
231
|
+
except Exception as e:
|
|
232
|
+
logging.debug(f"Could not get storage path from DatabaseManager: {e}")
|
|
233
|
+
|
|
234
|
+
# Create video sharing client
|
|
235
|
+
self.video_client = VideoSharingClient(self.device_index, storage_path=storage_path)
|
|
236
|
+
|
|
237
|
+
# Check if daemon info file exists and is valid (quick check, no long wait)
|
|
238
|
+
max_wait_time = 5 # Reduced wait time for daemon check
|
|
239
|
+
wait_interval = 1 # Check every 1 second
|
|
240
|
+
wait_elapsed = 0
|
|
241
|
+
|
|
242
|
+
logging.debug(f"🔄 [APP] Quick check for video sharing daemon for device {self.device_index}...")
|
|
243
|
+
|
|
244
|
+
while wait_elapsed < max_wait_time:
|
|
245
|
+
if self.video_client._load_daemon_info():
|
|
246
|
+
logging.info(f"✅ [APP] Found video sharing daemon for device {self.device_index}")
|
|
247
|
+
break
|
|
248
|
+
|
|
249
|
+
logging.debug(f"⏳ [APP] Checking for daemon... ({wait_elapsed}/{max_wait_time}s)")
|
|
250
|
+
time.sleep(wait_interval)
|
|
251
|
+
wait_elapsed += wait_interval
|
|
252
|
+
else:
|
|
253
|
+
# Timeout reached - daemon not available
|
|
254
|
+
logging.debug(f"⚠️ [APP] Video sharing daemon not available for device {self.device_index} within {max_wait_time}s")
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
# Connect to the video sharing daemon
|
|
258
|
+
if not self.video_client.connect(self._on_frame_received):
|
|
259
|
+
logging.error(f"❌ [APP] Failed to connect to video sharing daemon for device {self.device_index}")
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
# Get device properties
|
|
263
|
+
width, height, fps, pixel_format = self.video_client.get_device_properties()
|
|
264
|
+
self.fps = fps if fps > 0 else 30
|
|
265
|
+
|
|
266
|
+
logging.info(f"📡 [APP] Starting cross-process device to RTMP stream: {self.device_url} → {self.rtmp_url} for {self.duration} seconds")
|
|
267
|
+
logging.info(f"📹 [APP] Device properties: {width}x{height} @ {fps}fps")
|
|
268
|
+
|
|
269
|
+
# Record start time for duration tracking
|
|
270
|
+
self.start_time = time.time()
|
|
271
|
+
|
|
272
|
+
logging.info(f"✅ [APP] Cross-process device streaming configured successfully")
|
|
273
|
+
return True
|
|
274
|
+
|
|
275
|
+
except Exception as e:
|
|
276
|
+
logging.debug(f"🚨 [APP] Error starting cross-process device stream: {e}")
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
def _start_direct_opencv_streaming(self):
|
|
280
|
+
"""Start streaming using direct OpenCV access to the camera device."""
|
|
281
|
+
try:
|
|
282
|
+
logging.info(f"🎥 [APP] Starting direct OpenCV streaming for device {self.device_index}")
|
|
283
|
+
|
|
284
|
+
# Open camera with OpenCV
|
|
285
|
+
cap = cv2.VideoCapture(self.device_index)
|
|
286
|
+
if not cap.isOpened():
|
|
287
|
+
logging.error(f"❌ [APP] Failed to open device {self.device_index} with OpenCV")
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
# Set buffer size to reduce latency
|
|
291
|
+
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
|
292
|
+
|
|
293
|
+
# Get camera properties
|
|
294
|
+
original_fps = cap.get(cv2.CAP_PROP_FPS)
|
|
295
|
+
self.fps = original_fps if original_fps > 0 else 30
|
|
296
|
+
|
|
297
|
+
# Test reading a frame to ensure camera works
|
|
298
|
+
ret, test_frame = cap.read()
|
|
299
|
+
if not ret or test_frame is None:
|
|
300
|
+
logging.error(f"❌ [APP] Failed to read test frame from device {self.device_index}")
|
|
301
|
+
cap.release()
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
# Set resolution based on first frame
|
|
305
|
+
self.width, self.height = self._calculate_resolution(test_frame)
|
|
306
|
+
|
|
307
|
+
# Start FFmpeg process
|
|
308
|
+
if not self._start_ffmpeg_stream():
|
|
309
|
+
cap.release()
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
logging.info(f"✅ [APP] Direct OpenCV streaming started: {self.width}x{self.height} @ {self.fps}fps")
|
|
313
|
+
|
|
314
|
+
# Record start time for duration tracking
|
|
315
|
+
self.start_time = time.time()
|
|
316
|
+
self.active = True
|
|
317
|
+
|
|
318
|
+
# Start streaming thread
|
|
319
|
+
streaming_thread = threading.Thread(target=self._opencv_streaming_loop, args=(cap,), daemon=True)
|
|
320
|
+
streaming_thread.start()
|
|
321
|
+
|
|
322
|
+
return True
|
|
323
|
+
|
|
324
|
+
except Exception as e:
|
|
325
|
+
logging.error(f"❌ [APP] Error in direct OpenCV streaming: {e}", exc_info=True)
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
def _opencv_streaming_loop(self, cap):
|
|
329
|
+
"""Main loop for OpenCV streaming."""
|
|
330
|
+
try:
|
|
331
|
+
frame_interval = 1.0 / self.fps
|
|
332
|
+
last_frame_time = 0
|
|
333
|
+
|
|
334
|
+
while self.active and not self.stop_event.is_set():
|
|
335
|
+
current_time = time.time()
|
|
336
|
+
|
|
337
|
+
# Check duration limit
|
|
338
|
+
if self.start_time and (current_time - self.start_time) >= self.duration:
|
|
339
|
+
logging.info(f"⏰ [APP] Stream duration reached ({self.duration}s), stopping...")
|
|
340
|
+
break
|
|
341
|
+
|
|
342
|
+
# Frame rate control
|
|
343
|
+
if current_time - last_frame_time < frame_interval:
|
|
344
|
+
time.sleep(0.001) # Small sleep to prevent busy waiting
|
|
345
|
+
continue
|
|
346
|
+
|
|
347
|
+
ret, frame = cap.read()
|
|
348
|
+
if not ret or frame is None:
|
|
349
|
+
logging.warning(f"⚠️ [APP] Failed to read frame from device {self.device_index}")
|
|
350
|
+
time.sleep(0.1) # Wait before retrying
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
# Process and send frame
|
|
354
|
+
self._process_and_send_frame(frame)
|
|
355
|
+
last_frame_time = current_time
|
|
356
|
+
|
|
357
|
+
except Exception as e:
|
|
358
|
+
logging.error(f"❌ [APP] Error in OpenCV streaming loop: {e}", exc_info=True)
|
|
359
|
+
finally:
|
|
360
|
+
cap.release()
|
|
361
|
+
self.stop_stream()
|
|
362
|
+
|
|
363
|
+
def _process_and_send_frame(self, frame):
|
|
364
|
+
"""Process and send frame to RTMP stream."""
|
|
365
|
+
try:
|
|
366
|
+
if not self.is_active():
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
# Resize frame if necessary
|
|
370
|
+
if frame.shape[1] > 1024:
|
|
371
|
+
frame = cv2.resize(frame, (self.width, self.height), interpolation=cv2.INTER_AREA)
|
|
372
|
+
|
|
373
|
+
# Send frame to FFmpeg
|
|
374
|
+
if self.ffmpeg_process and self.ffmpeg_process.stdin:
|
|
375
|
+
self.ffmpeg_process.stdin.write(frame.tobytes())
|
|
376
|
+
|
|
377
|
+
except BrokenPipeError:
|
|
378
|
+
logging.error("❌ [APP] RTMP connection broken")
|
|
379
|
+
self.stop_stream()
|
|
380
|
+
except Exception as e:
|
|
381
|
+
logging.error(f"❌ [APP] Failed to send frame to RTMP: {e}")
|
|
382
|
+
self.stop_stream()
|
|
383
|
+
|
|
384
|
+
def _start_direct_ffmpeg_streaming(self):
|
|
385
|
+
"""Start streaming using direct FFmpeg access to the camera device."""
|
|
386
|
+
try:
|
|
387
|
+
logging.info(f"🎥 [APP] Starting direct FFmpeg streaming for device {self.device_index}")
|
|
388
|
+
|
|
389
|
+
# Determine platform-specific input format
|
|
390
|
+
system = platform.system().lower()
|
|
391
|
+
if system == "linux":
|
|
392
|
+
input_format = "v4l2"
|
|
393
|
+
device_path = f"/dev/video{self.device_index}"
|
|
394
|
+
elif system == "windows":
|
|
395
|
+
input_format = "dshow"
|
|
396
|
+
device_path = f"video={self.device_index}"
|
|
397
|
+
elif system == "darwin": # macOS
|
|
398
|
+
input_format = "avfoundation"
|
|
399
|
+
device_path = str(self.device_index)
|
|
400
|
+
else:
|
|
401
|
+
logging.error(f"❌ [APP] Unsupported platform for direct FFmpeg streaming: {system}")
|
|
402
|
+
return False
|
|
403
|
+
|
|
404
|
+
# Set default properties
|
|
405
|
+
self.width = 1024 # Will be adjusted by FFmpeg
|
|
406
|
+
self.height = 768
|
|
407
|
+
self.fps = 30
|
|
408
|
+
|
|
409
|
+
# Build FFmpeg command for direct device streaming
|
|
410
|
+
ffmpeg_command = [
|
|
411
|
+
"ffmpeg",
|
|
412
|
+
"-y",
|
|
413
|
+
"-loglevel", "warning",
|
|
414
|
+
"-nostats",
|
|
415
|
+
"-hide_banner",
|
|
416
|
+
"-f", input_format,
|
|
417
|
+
"-i", device_path,
|
|
418
|
+
"-c:v", "libx264",
|
|
419
|
+
"-preset", "ultrafast",
|
|
420
|
+
"-tune", "zerolatency",
|
|
421
|
+
"-b:v", self.bitrate,
|
|
422
|
+
"-an", # No audio
|
|
423
|
+
"-maxrate", "2500k",
|
|
424
|
+
"-bufsize", "5000k",
|
|
425
|
+
"-f", "flv"
|
|
426
|
+
]
|
|
427
|
+
|
|
428
|
+
# Add duration limit if specified
|
|
429
|
+
if self.duration > 0:
|
|
430
|
+
ffmpeg_command.extend(["-t", str(self.duration)])
|
|
431
|
+
|
|
432
|
+
ffmpeg_command.append(self.rtmp_url)
|
|
433
|
+
|
|
434
|
+
logging.debug(f"FFmpeg command: {' '.join(ffmpeg_command)}")
|
|
435
|
+
|
|
436
|
+
# Start FFmpeg process
|
|
437
|
+
with open(os.devnull, "w") as devnull:
|
|
438
|
+
self.ffmpeg_process = subprocess.Popen(
|
|
439
|
+
ffmpeg_command,
|
|
440
|
+
stdout=devnull,
|
|
441
|
+
stderr=subprocess.PIPE
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# Check if process started successfully
|
|
445
|
+
time.sleep(1) # Give FFmpeg time to initialize
|
|
446
|
+
if self.ffmpeg_process.poll() is not None:
|
|
447
|
+
# Process has already terminated
|
|
448
|
+
stderr_output = self.ffmpeg_process.stderr.read().decode('utf-8', errors='ignore')
|
|
449
|
+
logging.error(f"❌ [APP] FFmpeg process failed to start: {stderr_output}")
|
|
450
|
+
return False
|
|
451
|
+
|
|
452
|
+
self.active = True
|
|
453
|
+
self.start_time = time.time()
|
|
454
|
+
|
|
455
|
+
# Start stderr monitoring
|
|
456
|
+
stderr_thread = threading.Thread(target=self._monitor_ffmpeg_stderr, daemon=True)
|
|
457
|
+
stderr_thread.start()
|
|
458
|
+
|
|
459
|
+
# Start duration monitoring
|
|
460
|
+
duration_thread = threading.Thread(target=self._monitor_duration, daemon=True)
|
|
461
|
+
duration_thread.start()
|
|
462
|
+
|
|
463
|
+
logging.info(f"✅ [APP] Direct FFmpeg streaming started for device {self.device_index}")
|
|
464
|
+
return True
|
|
465
|
+
|
|
466
|
+
except Exception as e:
|
|
467
|
+
logging.error(f"❌ [APP] Error in direct FFmpeg streaming: {e}", exc_info=True)
|
|
468
|
+
return False
|
|
469
|
+
|
|
470
|
+
def _monitor_duration(self):
|
|
471
|
+
"""Monitor streaming duration and stop when limit is reached."""
|
|
472
|
+
try:
|
|
473
|
+
while self.active and not self.stop_event.is_set():
|
|
474
|
+
if self.start_time and (time.time() - self.start_time) >= self.duration:
|
|
475
|
+
logging.info(f"⏰ [APP] Stream duration reached ({self.duration}s), stopping...")
|
|
476
|
+
self.stop_stream()
|
|
477
|
+
break
|
|
478
|
+
time.sleep(1)
|
|
479
|
+
except Exception as e:
|
|
480
|
+
logging.error(f"❌ [APP] Error monitoring duration: {e}")
|
|
481
|
+
|
|
482
|
+
def _on_frame_received(self, frame, timestamp=None):
|
|
483
|
+
"""Callback when frame is received from video sharing daemon."""
|
|
484
|
+
try:
|
|
485
|
+
# Send frame directly to RTMP
|
|
486
|
+
self.send_frame(frame)
|
|
487
|
+
except Exception as e:
|
|
488
|
+
logging.warning(f"Error processing frame: {e}")
|
|
489
|
+
|
|
490
|
+
def _cleanup_on_error(self):
|
|
491
|
+
"""Clean up resources on error."""
|
|
492
|
+
try:
|
|
493
|
+
if self.video_client:
|
|
494
|
+
self.video_client.disconnect()
|
|
495
|
+
self.video_client = None
|
|
496
|
+
except Exception as e:
|
|
497
|
+
logging.warning(f"Error during cleanup: {e}")
|
|
498
|
+
|
|
499
|
+
def stop_stream(self):
|
|
500
|
+
"""Stops the FFmpeg streaming process and disconnects from video sharing."""
|
|
501
|
+
logging.info(f"🛑 [APP] Stopping device stream")
|
|
502
|
+
|
|
503
|
+
self.active = False
|
|
504
|
+
self.stop_event.set()
|
|
505
|
+
|
|
506
|
+
if self.ffmpeg_process:
|
|
507
|
+
try:
|
|
508
|
+
if self.ffmpeg_process.stdin:
|
|
509
|
+
self.ffmpeg_process.stdin.close()
|
|
510
|
+
self.ffmpeg_process.terminate()
|
|
511
|
+
self.ffmpeg_process.wait(timeout=5)
|
|
512
|
+
except Exception as e:
|
|
513
|
+
logging.error(f"❌ [APP] Error stopping RTMP stream: {e}")
|
|
514
|
+
# Force kill if normal termination fails
|
|
515
|
+
try:
|
|
516
|
+
self.ffmpeg_process.kill()
|
|
517
|
+
except Exception:
|
|
518
|
+
pass
|
|
519
|
+
finally:
|
|
520
|
+
self.ffmpeg_process = None
|
|
521
|
+
logging.info("✅ [APP] RTMP streaming process stopped.")
|
|
522
|
+
|
|
523
|
+
# Disconnect from video sharing
|
|
524
|
+
if self.video_client:
|
|
525
|
+
try:
|
|
526
|
+
self.video_client.disconnect()
|
|
527
|
+
self.video_client = None
|
|
528
|
+
logging.info(f"🔓 [APP] Disconnected from video sharing daemon")
|
|
529
|
+
except Exception as e:
|
|
530
|
+
logging.warning(f"⚠️ [APP] Error disconnecting from video sharing: {e}")
|
|
531
|
+
|
|
532
|
+
def is_running(self):
|
|
533
|
+
"""Check if the streaming process is running."""
|
|
534
|
+
return self.is_active()
|