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.
Files changed (44) hide show
  1. nedo_vision_worker/__init__.py +1 -1
  2. nedo_vision_worker/cli.py +196 -167
  3. nedo_vision_worker/database/DatabaseManager.py +3 -3
  4. nedo_vision_worker/doctor.py +1066 -386
  5. nedo_vision_worker/models/ai_model.py +35 -2
  6. nedo_vision_worker/protos/AIModelService_pb2.py +12 -10
  7. nedo_vision_worker/protos/AIModelService_pb2_grpc.py +1 -1
  8. nedo_vision_worker/protos/DatasetSourceService_pb2.py +2 -2
  9. nedo_vision_worker/protos/DatasetSourceService_pb2_grpc.py +1 -1
  10. nedo_vision_worker/protos/HumanDetectionService_pb2.py +2 -2
  11. nedo_vision_worker/protos/HumanDetectionService_pb2_grpc.py +1 -1
  12. nedo_vision_worker/protos/PPEDetectionService_pb2.py +2 -2
  13. nedo_vision_worker/protos/PPEDetectionService_pb2_grpc.py +1 -1
  14. nedo_vision_worker/protos/VisionWorkerService_pb2.py +2 -2
  15. nedo_vision_worker/protos/VisionWorkerService_pb2_grpc.py +1 -1
  16. nedo_vision_worker/protos/WorkerSourcePipelineService_pb2.py +2 -2
  17. nedo_vision_worker/protos/WorkerSourcePipelineService_pb2_grpc.py +1 -1
  18. nedo_vision_worker/protos/WorkerSourceService_pb2.py +2 -2
  19. nedo_vision_worker/protos/WorkerSourceService_pb2_grpc.py +1 -1
  20. nedo_vision_worker/services/AIModelClient.py +184 -160
  21. nedo_vision_worker/services/DirectDeviceToRTMPStreamer.py +534 -0
  22. nedo_vision_worker/services/GrpcClientBase.py +142 -108
  23. nedo_vision_worker/services/PPEDetectionClient.py +0 -7
  24. nedo_vision_worker/services/RestrictedAreaClient.py +0 -5
  25. nedo_vision_worker/services/SharedDirectDeviceClient.py +278 -0
  26. nedo_vision_worker/services/SharedVideoStreamServer.py +315 -0
  27. nedo_vision_worker/services/SystemWideDeviceCoordinator.py +236 -0
  28. nedo_vision_worker/services/VideoSharingDaemon.py +832 -0
  29. nedo_vision_worker/services/VideoStreamClient.py +43 -20
  30. nedo_vision_worker/services/WorkerSourceClient.py +1 -1
  31. nedo_vision_worker/services/WorkerSourcePipelineClient.py +35 -12
  32. nedo_vision_worker/services/WorkerSourceUpdater.py +30 -3
  33. nedo_vision_worker/util/FFmpegUtil.py +124 -0
  34. nedo_vision_worker/util/VideoProbeUtil.py +227 -17
  35. nedo_vision_worker/worker/DataSyncWorker.py +1 -0
  36. nedo_vision_worker/worker/PipelineImageWorker.py +1 -1
  37. nedo_vision_worker/worker/VideoStreamWorker.py +27 -3
  38. nedo_vision_worker/worker/WorkerManager.py +2 -29
  39. nedo_vision_worker/worker_service.py +22 -5
  40. {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.1.dist-info}/METADATA +1 -3
  41. {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.1.dist-info}/RECORD +44 -38
  42. {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.1.dist-info}/WHEEL +0 -0
  43. {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.1.dist-info}/entry_points.txt +0 -0
  44. {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()