matrice-streaming 0.1.62__tar.gz → 0.1.64__tar.gz

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 (71) hide show
  1. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/PKG-INFO +1 -1
  2. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/matrice_streaming.egg-info/PKG-INFO +1 -1
  3. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/nvdec.py +333 -14
  4. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/LICENSE.txt +0 -0
  5. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/README.md +0 -0
  6. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/matrice_streaming.egg-info/SOURCES.txt +0 -0
  7. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/matrice_streaming.egg-info/dependency_links.txt +0 -0
  8. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/matrice_streaming.egg-info/not-zip-safe +0 -0
  9. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/matrice_streaming.egg-info/top_level.txt +0 -0
  10. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/pyproject.toml +0 -0
  11. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/setup.cfg +0 -0
  12. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/setup.py +0 -0
  13. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/__init__.py +0 -0
  14. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/client/__init__.py +0 -0
  15. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/client/client.py +0 -0
  16. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/client/client_utils.py +0 -0
  17. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/deployment/__init__.py +0 -0
  18. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/deployment/camera_manager.py +0 -0
  19. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/deployment/deployment.py +0 -0
  20. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/deployment/inference_pipeline.py +0 -0
  21. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/deployment/streaming_gateway_manager.py +0 -0
  22. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/deployment/todo.txt +0 -0
  23. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/py.typed +0 -0
  24. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/__init__.py +0 -0
  25. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/ARCHITECTURE.md +0 -0
  26. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/__init__.py +0 -0
  27. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/async_camera_worker.py +0 -0
  28. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/async_ffmpeg_worker.py +0 -0
  29. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/camera_streamer.py +0 -0
  30. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/device_detection.py +0 -0
  31. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/encoder_manager.py +0 -0
  32. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/encoding_pool_manager.py +0 -0
  33. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/ffmpeg_camera_streamer.py +0 -0
  34. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/ffmpeg_config.py +0 -0
  35. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/ffmpeg_worker_manager.py +0 -0
  36. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/frame_processor.py +0 -0
  37. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/gstreamer_camera_streamer.py +0 -0
  38. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/gstreamer_worker.py +0 -0
  39. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/gstreamer_worker_manager.py +0 -0
  40. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/message_builder.py +0 -0
  41. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/nvdec_worker_manager.py +0 -0
  42. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/platform_pipelines.py +0 -0
  43. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/retry_manager.py +0 -0
  44. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/stream_statistics.py +0 -0
  45. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/video_capture_manager.py +0 -0
  46. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/camera_streamer/worker_manager.py +0 -0
  47. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/debug/README.md +0 -0
  48. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/debug/__init__.py +0 -0
  49. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/debug/benchmark.py +0 -0
  50. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/debug/debug_gstreamer_gateway.py +0 -0
  51. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/debug/debug_stream_backend.py +0 -0
  52. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/debug/debug_streaming_gateway.py +0 -0
  53. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/debug/debug_utils.py +0 -0
  54. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/debug/example_debug_streaming.py +0 -0
  55. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/debug/test_videoplayback.py +0 -0
  56. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/dynamic_camera_manager.py +0 -0
  57. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/event_listener.py +0 -0
  58. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/metrics_reporter.py +0 -0
  59. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/streaming_action.py +0 -0
  60. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/streaming_gateway.py +0 -0
  61. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/streaming_gateway_utils.py +0 -0
  62. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/src/matrice_streaming/streaming_gateway/streaming_status_listener.py +0 -0
  63. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/tests/test_async_infrastructure.py +0 -0
  64. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/tests/test_batch_auto_calculation.py +0 -0
  65. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/tests/test_batching_verification.py +0 -0
  66. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/tests/test_e2e_production.py +0 -0
  67. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/tests/test_flatten_binary.py +0 -0
  68. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/tests/test_gstreamer_integration.py +0 -0
  69. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/tests/test_msgpack_fix.py +0 -0
  70. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/tests/test_phase1_unit.py +0 -0
  71. {matrice_streaming-0.1.62 → matrice_streaming-0.1.64}/tests/test_phase2_scaling.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrice_streaming
3
- Version: 0.1.62
3
+ Version: 0.1.64
4
4
  Summary: Common server utilities for Matrice.ai services
5
5
  Author-email: "Matrice.ai" <dipendra@matrice.ai>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrice_streaming
3
- Version: 0.1.62
3
+ Version: 0.1.64
4
4
  Summary: Common server utilities for Matrice.ai services
5
5
  Author-email: "Matrice.ai" <dipendra@matrice.ai>
6
6
  License-Expression: MIT
@@ -56,11 +56,21 @@ import os
56
56
  import time
57
57
  import threading
58
58
  import queue as thread_queue
59
+ import hashlib
60
+ import tempfile
59
61
  from dataclasses import dataclass
62
+ from pathlib import Path
60
63
  from typing import Dict, List, Optional, Tuple, Any
64
+ from urllib.parse import urlparse, urlunparse
61
65
 
62
66
  import numpy as np
63
67
 
68
+ try:
69
+ import requests
70
+ REQUESTS_AVAILABLE = True
71
+ except ImportError:
72
+ REQUESTS_AVAILABLE = False
73
+
64
74
  try:
65
75
  import cupy as cp
66
76
  CUPY_AVAILABLE = True
@@ -93,6 +103,267 @@ def setup_logging(quiet: bool = True):
93
103
  logging.getLogger('cuda_shm_ring_buffer').setLevel(logging.WARNING if quiet else logging.INFO)
94
104
 
95
105
 
106
+ # =============================================================================
107
+ # Video Downloader for HTTPS URLs (PyNvVideoCodec's FFmpeg lacks HTTPS support)
108
+ # =============================================================================
109
+
110
+ class VideoDownloader:
111
+ """Downloads and caches video files from HTTPS URLs.
112
+
113
+ PyNvVideoCodec uses a bundled FFmpeg that doesn't have HTTPS support.
114
+ This class downloads HTTPS videos to local files before passing them
115
+ to the NVDEC demuxer.
116
+
117
+ Features:
118
+ - URL deduplication: same video URL (ignoring query params) is only downloaded once
119
+ - Disk caching: reuses existing files across runs
120
+ - Progress tracking for large files
121
+ - Dynamic timeout based on file size
122
+ """
123
+
124
+ # Configuration
125
+ DOWNLOAD_TIMEOUT = 300 # Base timeout in seconds
126
+ DOWNLOAD_TIMEOUT_PER_100MB = 300 # Additional seconds per 100MB
127
+ MAX_DOWNLOAD_TIMEOUT = 6000 # 100 minutes max
128
+ DOWNLOAD_CHUNK_SIZE = 8192
129
+
130
+ # Singleton instance for process-wide caching
131
+ _instance: Optional['VideoDownloader'] = None
132
+ _lock = threading.Lock()
133
+
134
+ def __new__(cls):
135
+ """Singleton pattern for process-wide cache sharing."""
136
+ if cls._instance is None:
137
+ with cls._lock:
138
+ if cls._instance is None:
139
+ cls._instance = super().__new__(cls)
140
+ cls._instance._initialized = False
141
+ return cls._instance
142
+
143
+ def __init__(self):
144
+ """Initialize the video downloader."""
145
+ if self._initialized:
146
+ return
147
+
148
+ self._initialized = True
149
+ self.downloaded_files: Dict[str, str] = {}
150
+ self._normalized_url_to_path: Dict[str, str] = {}
151
+ self._download_lock = threading.Lock()
152
+ self.temp_dir = Path(tempfile.gettempdir()) / "nvdec_video_cache"
153
+ self.temp_dir.mkdir(exist_ok=True)
154
+ logger.info(f"VideoDownloader initialized, cache dir: {self.temp_dir}")
155
+
156
+ def prepare_source(self, video_path: str, camera_id: str) -> str:
157
+ """Prepare video source, downloading HTTPS URLs if needed.
158
+
159
+ Args:
160
+ video_path: Video file path, RTSP URL, or HTTPS URL
161
+ camera_id: Camera identifier for logging
162
+
163
+ Returns:
164
+ Local file path (downloaded if HTTPS) or original path
165
+ """
166
+ if not self._is_https_url(video_path):
167
+ return video_path
168
+
169
+ if not REQUESTS_AVAILABLE:
170
+ logger.warning(f"requests module not available, cannot download HTTPS URL for {camera_id}")
171
+ return video_path
172
+
173
+ local_path = self._download_video(video_path, camera_id)
174
+ if local_path:
175
+ return local_path
176
+
177
+ logger.warning(f"Failed to download {video_path} for {camera_id}, will try URL directly (may fail)")
178
+ return video_path
179
+
180
+ def _is_https_url(self, source: str) -> bool:
181
+ """Check if source is an HTTPS URL."""
182
+ return source.startswith('https://')
183
+
184
+ def _normalize_url(self, url: str) -> str:
185
+ """Normalize URL by stripping query parameters for deduplication."""
186
+ parsed = urlparse(url)
187
+ return urlunparse((
188
+ parsed.scheme,
189
+ parsed.netloc,
190
+ parsed.path,
191
+ '', '', '' # params, query, fragment
192
+ ))
193
+
194
+ def _get_url_hash(self, normalized_url: str) -> str:
195
+ """Generate a short hash for consistent file naming."""
196
+ return hashlib.md5(normalized_url.encode()).hexdigest()[:12]
197
+
198
+ def _download_video(self, url: str, camera_id: str) -> Optional[str]:
199
+ """Download video file from HTTPS URL with caching.
200
+
201
+ Thread-safe: uses lock to prevent duplicate downloads.
202
+
203
+ Args:
204
+ url: HTTPS video URL
205
+ camera_id: Camera identifier for logging
206
+
207
+ Returns:
208
+ Local file path or None if download failed
209
+ """
210
+ normalized_url = self._normalize_url(url)
211
+ file_ext = Path(url.split('?')[0]).suffix or '.mp4'
212
+ url_hash = self._get_url_hash(normalized_url)
213
+ expected_path = self.temp_dir / f"video_{url_hash}{file_ext}"
214
+ expected_path_str = str(expected_path)
215
+
216
+ # Quick check: file already on disk
217
+ if expected_path.exists():
218
+ existing_size = expected_path.stat().st_size
219
+ logger.info(
220
+ f"[{camera_id}] Reusing cached video: {expected_path.name} "
221
+ f"({existing_size / (1024*1024):.1f}MB)"
222
+ )
223
+ with self._download_lock:
224
+ self.downloaded_files[url] = expected_path_str
225
+ self._normalized_url_to_path[normalized_url] = expected_path_str
226
+ return expected_path_str
227
+
228
+ # Check memory cache
229
+ with self._download_lock:
230
+ if url in self.downloaded_files:
231
+ local_path = self.downloaded_files[url]
232
+ if os.path.exists(local_path):
233
+ logger.debug(f"[{camera_id}] Using cached path (exact URL match)")
234
+ return local_path
235
+
236
+ if normalized_url in self._normalized_url_to_path:
237
+ local_path = self._normalized_url_to_path[normalized_url]
238
+ if os.path.exists(local_path):
239
+ logger.info(f"[{camera_id}] Reusing download (same base URL)")
240
+ self.downloaded_files[url] = local_path
241
+ return local_path
242
+
243
+ # Need to download - acquire lock to prevent duplicate downloads
244
+ with self._download_lock:
245
+ # Double-check after acquiring lock
246
+ if expected_path.exists():
247
+ self.downloaded_files[url] = expected_path_str
248
+ self._normalized_url_to_path[normalized_url] = expected_path_str
249
+ return expected_path_str
250
+
251
+ return self._do_download(url, expected_path, camera_id)
252
+
253
+ def _do_download(self, url: str, dest_path: Path, camera_id: str) -> Optional[str]:
254
+ """Perform the actual download. Must be called with _download_lock held."""
255
+ content_length = 0
256
+ file_size_mb = 0.0
257
+ bytes_downloaded = 0
258
+ timeout = self.DOWNLOAD_TIMEOUT
259
+
260
+ try:
261
+ # HEAD request to get file size
262
+ try:
263
+ head_response = requests.head(url, timeout=10, allow_redirects=True)
264
+ content_length = int(head_response.headers.get('Content-Length', 0))
265
+ file_size_mb = content_length / (1024 * 1024)
266
+ except Exception as e:
267
+ logger.debug(f"[{camera_id}] HEAD request failed: {e}")
268
+
269
+ # Calculate dynamic timeout
270
+ if content_length > 0:
271
+ timeout = min(
272
+ self.DOWNLOAD_TIMEOUT + int(file_size_mb // 100) * self.DOWNLOAD_TIMEOUT_PER_100MB,
273
+ self.MAX_DOWNLOAD_TIMEOUT
274
+ )
275
+ logger.info(f"[{camera_id}] Downloading {file_size_mb:.1f}MB (timeout: {timeout}s)")
276
+ else:
277
+ logger.info(f"[{camera_id}] Downloading video (size unknown, timeout: {timeout}s)")
278
+
279
+ # Download with progress tracking
280
+ response = requests.get(url, stream=True, timeout=timeout)
281
+ response.raise_for_status()
282
+
283
+ if content_length == 0:
284
+ content_length = int(response.headers.get('Content-Length', 0))
285
+ file_size_mb = content_length / (1024 * 1024) if content_length > 0 else 0
286
+
287
+ last_progress_log = 0
288
+
289
+ with open(dest_path, 'wb') as f:
290
+ for chunk in response.iter_content(chunk_size=self.DOWNLOAD_CHUNK_SIZE):
291
+ f.write(chunk)
292
+ bytes_downloaded += len(chunk)
293
+
294
+ # Log progress every 50MB for large files
295
+ if content_length > 50_000_000:
296
+ mb_downloaded = bytes_downloaded // (1024 * 1024)
297
+ if mb_downloaded - last_progress_log >= 50:
298
+ progress = (bytes_downloaded / content_length * 100) if content_length else 0
299
+ logger.info(
300
+ f"[{camera_id}] Download progress: "
301
+ f"{mb_downloaded}MB / {file_size_mb:.0f}MB ({progress:.1f}%)"
302
+ )
303
+ last_progress_log = mb_downloaded
304
+
305
+ # Update caches
306
+ normalized_url = self._normalize_url(url)
307
+ dest_path_str = str(dest_path)
308
+ self.downloaded_files[url] = dest_path_str
309
+ self._normalized_url_to_path[normalized_url] = dest_path_str
310
+
311
+ logger.info(
312
+ f"[{camera_id}] Downloaded: {dest_path.name} "
313
+ f"({bytes_downloaded / (1024*1024):.1f}MB)"
314
+ )
315
+ return dest_path_str
316
+
317
+ except requests.Timeout:
318
+ logger.error(
319
+ f"[{camera_id}] Download timeout: {file_size_mb:.1f}MB, "
320
+ f"got {bytes_downloaded/(1024*1024):.1f}MB in {timeout}s"
321
+ )
322
+ except requests.HTTPError as e:
323
+ logger.error(f"[{camera_id}] HTTP error: {e.response.status_code} - {e.response.reason}")
324
+ except IOError as e:
325
+ logger.error(f"[{camera_id}] Disk I/O error: {e}")
326
+ except Exception as e:
327
+ logger.error(f"[{camera_id}] Download failed: {type(e).__name__}: {e}")
328
+
329
+ # Cleanup partial download
330
+ try:
331
+ if dest_path.exists():
332
+ dest_path.unlink()
333
+ except Exception:
334
+ pass
335
+
336
+ return None
337
+
338
+ def cleanup(self):
339
+ """Clean up downloaded temporary files."""
340
+ unique_files = set(self.downloaded_files.values())
341
+ unique_files.update(self._normalized_url_to_path.values())
342
+
343
+ for filepath in unique_files:
344
+ try:
345
+ if os.path.exists(filepath):
346
+ os.remove(filepath)
347
+ logger.debug(f"Removed temp file: {filepath}")
348
+ except Exception as e:
349
+ logger.warning(f"Failed to remove temp file {filepath}: {e}")
350
+
351
+ self.downloaded_files.clear()
352
+ self._normalized_url_to_path.clear()
353
+
354
+
355
+ # Global video downloader instance
356
+ _video_downloader: Optional[VideoDownloader] = None
357
+
358
+
359
+ def get_video_downloader() -> VideoDownloader:
360
+ """Get or create the global VideoDownloader instance."""
361
+ global _video_downloader
362
+ if _video_downloader is None:
363
+ _video_downloader = VideoDownloader()
364
+ return _video_downloader
365
+
366
+
96
367
  @dataclass
97
368
  class StreamConfig:
98
369
  """Configuration for a single video stream."""
@@ -276,7 +547,12 @@ def surface_to_nv12(frame, target_h: int = 640, target_w: int = 640) -> Optional
276
547
  return nv12_frame[:, :, cp.newaxis] if nv12_frame is not None else None
277
548
 
278
549
  except Exception as e:
279
- logger.warning(f"surface_to_nv12 failed: {e}")
550
+ # Safely encode error message (some CUDA errors contain non-ASCII chars like '×')
551
+ try:
552
+ err_msg = str(e).encode('ascii', errors='replace').decode('ascii')
553
+ except Exception:
554
+ err_msg = "unknown error"
555
+ logger.warning(f"surface_to_nv12 failed: {err_msg}")
280
556
  return None
281
557
 
282
558
 
@@ -320,13 +596,22 @@ class NVDECDecoderPool:
320
596
 
321
597
  def assign_stream(self, stream_id: int, camera_id: str, video_path: str,
322
598
  width: int = 640, height: int = 640) -> bool:
323
- """Assign a stream to a decoder (round-robin)."""
599
+ """Assign a stream to a decoder (round-robin).
600
+
601
+ Automatically downloads HTTPS URLs to local files since PyNvVideoCodec's
602
+ bundled FFmpeg doesn't support HTTPS protocol.
603
+ """
324
604
  if self.actual_pool_size == 0:
325
605
  return False
326
606
 
327
607
  decoder_idx = stream_id % self.actual_pool_size
608
+
609
+ # Download HTTPS URLs to local files (PyNvVideoCodec lacks HTTPS support)
610
+ downloader = get_video_downloader()
611
+ local_path = downloader.prepare_source(video_path, camera_id)
612
+
328
613
  try:
329
- demuxer = nvc.CreateDemuxer(video_path)
614
+ demuxer = nvc.CreateDemuxer(local_path)
330
615
  except Exception as e:
331
616
  logger.error(f"Failed to create demuxer for {camera_id}: {e}")
332
617
  return False
@@ -334,7 +619,7 @@ class NVDECDecoderPool:
334
619
  stream_state = StreamState(
335
620
  stream_id=stream_id,
336
621
  camera_id=camera_id,
337
- video_path=video_path,
622
+ video_path=local_path, # Store local path for video looping
338
623
  demuxer=demuxer,
339
624
  width=width,
340
625
  height=height
@@ -447,7 +732,6 @@ def nvdec_pool_worker(
447
732
  frames_since_counter_update = 0
448
733
  counter_batch_size = 100
449
734
  start_time = time.perf_counter()
450
- last_log_time = start_time
451
735
  camera_ids = pool.get_camera_ids_for_decoder(decoder_idx)
452
736
  num_streams = len(camera_ids)
453
737
 
@@ -465,19 +749,12 @@ def nvdec_pool_worker(
465
749
  next_frame_time = 0
466
750
  fps_mode = ", unlimited FPS"
467
751
 
468
- logger.info(f"Worker {worker_id}: decoder={decoder_idx}, cams={num_streams}{fps_mode}")
752
+ logger.debug(f"Worker {worker_id}: decoder={decoder_idx}, cams={num_streams}{fps_mode}")
469
753
 
470
754
  while not stop_event.is_set():
471
755
  if time.perf_counter() - start_time >= duration_sec:
472
756
  break
473
757
 
474
- now = time.perf_counter()
475
- if now - last_log_time >= 5.0:
476
- elapsed = now - start_time
477
- fps = local_frames / elapsed if elapsed > 0 else 0
478
- logger.info(f"Worker {worker_id}: {local_frames} frames, {fps:.0f} FPS")
479
- last_log_time = now
480
-
481
758
  # FPS limiting: wait until next scheduled frame time
482
759
  if fps_limit_enabled:
483
760
  current_time = time.perf_counter()
@@ -672,10 +949,52 @@ def nvdec_pool_process(
672
949
  t.start()
673
950
  threads.append(t)
674
951
 
952
+ # Progress monitoring loop with current/avg FPS tracking
675
953
  start_time = time.perf_counter()
954
+ last_report_time = start_time
955
+ last_frame_count = 0
956
+ report_interval = 5.0
957
+ processing_start_time = None
958
+ frames_at_processing_start = 0
959
+
676
960
  while not stop_event.is_set():
677
- if time.perf_counter() - start_time >= duration_sec:
961
+ current_time = time.perf_counter()
962
+ if current_time - start_time >= duration_sec:
678
963
  break
964
+
965
+ # Periodic progress report with current and average FPS
966
+ if current_time - last_report_time >= report_interval:
967
+ elapsed = current_time - start_time
968
+
969
+ if shared_frame_count is not None:
970
+ current_frames = shared_frame_count.value
971
+ interval_frames = current_frames - last_frame_count
972
+ interval_fps = interval_frames / report_interval
973
+ per_stream_fps = interval_fps / len(camera_configs) if camera_configs else 0
974
+
975
+ # Track when processing actually starts (exclude warmup)
976
+ if processing_start_time is None and current_frames > 0:
977
+ processing_start_time = last_report_time
978
+ frames_at_processing_start = last_frame_count
979
+
980
+ # Calculate average FPS excluding warmup
981
+ if processing_start_time is not None:
982
+ processing_elapsed = current_time - processing_start_time
983
+ processing_frames = current_frames - frames_at_processing_start
984
+ avg_fps = processing_frames / processing_elapsed if processing_elapsed > 0 else 0
985
+ avg_per_stream = avg_fps / len(camera_configs) if camera_configs else 0
986
+ remaining = max(0, duration_sec - elapsed)
987
+
988
+ logger.info(
989
+ f"GPU{gpu_id} [{elapsed:5.1f}s] {current_frames:,} frames | "
990
+ f"cur: {interval_fps:,.0f} FPS ({per_stream_fps:.1f}/stream) | "
991
+ f"avg: {avg_fps:,.0f} FPS ({avg_per_stream:.1f}/stream) | "
992
+ f"{remaining:.0f}s left"
993
+ )
994
+
995
+ last_frame_count = current_frames
996
+ last_report_time = current_time
997
+
679
998
  time.sleep(0.1)
680
999
 
681
1000
  thread_stop_event.set()