nedo-vision-worker-core 0.3.1__py3-none-any.whl → 0.3.2__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,12 +1,13 @@
1
1
  import os
2
2
  import cv2
3
- import threading
4
3
  import time
4
+ import threading
5
5
  import logging
6
- from typing import Optional, Union
6
+ from typing import Optional, Union, List, Dict
7
7
  from enum import Enum
8
8
 
9
9
 
10
+ # ---------- States ----------
10
11
  class StreamState(Enum):
11
12
  DISCONNECTED = "disconnected"
12
13
  CONNECTING = "connecting"
@@ -15,131 +16,191 @@ class StreamState(Enum):
15
16
  STOPPED = "stopped"
16
17
 
17
18
 
19
+ # ---------- FFmpeg / RTSP tuning (more tolerant; avoids post-keyframe freeze) ----------
20
+ def set_ffmpeg_rtsp_env(
21
+ *,
22
+ prefer_tcp: bool = True,
23
+ probesize: str = "256k",
24
+ analyzeduration_us: int = 1_000_000, # 1s (non-zero)
25
+ buffer_size: str = "256k",
26
+ max_delay_us: int = 700_000, # 0.7s
27
+ stimeout_us: int = 5_000_000 # 5s socket timeout
28
+ ) -> None:
29
+ opts = [
30
+ f"rtsp_transport;{'tcp' if prefer_tcp else 'udp'}",
31
+ f"probesize;{probesize}",
32
+ f"analyzeduration;{analyzeduration_us}",
33
+ f"buffer_size;{buffer_size}",
34
+ f"max_delay;{max_delay_us}",
35
+ f"stimeout;{stimeout_us}",
36
+ "flags;low_delay",
37
+ "rtsp_flags;prefer_tcp" if prefer_tcp else "",
38
+ # NOTE: do NOT set reorder_queue_size=0 here; let ffmpeg reorder if needed.
39
+ ]
40
+ os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = ";".join([o for o in opts if o])
41
+
42
+
43
+ # ---------- VideoStream (low-latency, freeze-safe) ----------
18
44
  class VideoStream(threading.Thread):
19
- """Thread-safe video capture with automatic reconnection and error handling."""
20
-
45
+ """
46
+ RTSP/file capture that:
47
+ - Primes for first frame (bounded) and immediately enters steady-state.
48
+ - Publishes only the freshest frame (double-buffer).
49
+ - Adds a no-progress watchdog to force reconnect if frames stall.
50
+ - Uses tolerant FFmpeg options to avoid 'first-frame then freeze'.
51
+ """
52
+
21
53
  def __init__(
22
- self,
23
- source: Union[str, int],
54
+ self,
55
+ source: Union[str, int],
56
+ *,
24
57
  reconnect_interval: float = 5.0,
25
58
  max_failures: int = 5,
26
59
  max_reconnect_attempts: int = 10,
27
60
  backoff_factor: float = 1.5,
28
- target_fps: Optional[float] = None
61
+ max_sleep_backoff: float = 60.0,
62
+ target_fps: Optional[float] = None, # consumer pacing; capture runs free
63
+ enable_backlog_drain: bool = False, # keep False; can enable later if stable
64
+ ffmpeg_prefer_tcp: bool = True
29
65
  ):
30
66
  super().__init__(daemon=True)
31
-
67
+
68
+ # config
32
69
  self.source = source
33
70
  self.reconnect_interval = reconnect_interval
34
71
  self.max_failures = max_failures
35
72
  self.max_reconnect_attempts = max_reconnect_attempts
36
73
  self.backoff_factor = backoff_factor
74
+ self.max_sleep_backoff = max_sleep_backoff
37
75
  self.target_fps = target_fps
38
-
39
- self.capture = None
40
- self.state = StreamState.DISCONNECTED
41
- self.fps = 30.0
42
- self.frame_count = 0
43
- self.start_time = time.time()
44
-
45
- self._running = True
46
- self._lock = threading.Lock()
47
- self._latest_frame = None
76
+ self._drain_backlog = enable_backlog_drain
77
+
78
+ # runtime
79
+ self.capture: Optional[cv2.VideoCapture] = None
80
+ self.state: StreamState = StreamState.DISCONNECTED
81
+ self.fps: float = 30.0
82
+ self.frame_count: int = 0
83
+ self.start_time: float = time.time()
84
+ self._running: bool = True
85
+
86
+ # first-frame signaling
87
+ self._first_frame_evt = threading.Event()
88
+
89
+ # latest-frame (short lock)
90
+ self._latest_frame_lock = threading.Lock()
91
+ self._latest_frame: Optional[cv2.Mat] = None
92
+
93
+ # double buffer (very short lock)
94
+ self._buffer_lock = threading.Lock()
95
+ self._buf_a: Optional[cv2.Mat] = None
96
+ self._buf_b: Optional[cv2.Mat] = None
97
+ self._active_buf: str = "a"
98
+
99
+ # reconnect backoff
48
100
  self._reconnect_attempts = 0
49
101
  self._current_interval = reconnect_interval
50
-
51
- self.is_file = self._is_file_source()
52
-
53
- # Error tracking for diagnostics
54
- self._recent_errors = []
102
+
103
+ # diagnostics
104
+ self._recent_errors: List[Dict[str, Union[str, float]]] = []
55
105
  self._max_error_history = 50
56
- self._codec_info = None
57
-
106
+ self._codec_info: Optional[str] = None
107
+
108
+ # progress watchdog
109
+ self._last_frame_ts: float = 0.0
110
+
111
+ # type
112
+ self.is_file = self._is_file_source()
113
+
114
+ # set FFmpeg/RTSP options (tolerant profile)
115
+ set_ffmpeg_rtsp_env(prefer_tcp=ffmpeg_prefer_tcp)
116
+
117
+ # ----- helpers -----
58
118
  def _is_file_source(self) -> bool:
59
- """Check if source is a file path."""
60
119
  if isinstance(self.source, int):
61
120
  return False
62
121
  return isinstance(self.source, (str, bytes, os.PathLike)) and os.path.isfile(str(self.source))
63
-
122
+
64
123
  def _get_source_for_cv2(self) -> Union[str, int]:
65
- """Convert source to format suitable for cv2.VideoCapture."""
66
124
  if isinstance(self.source, str) and self.source.isdigit():
67
125
  return int(self.source)
68
126
  return self.source
69
-
127
+
70
128
  def _initialize_capture(self) -> bool:
71
- """Initialize video capture device."""
72
129
  try:
73
130
  self.state = StreamState.CONNECTING
74
131
  logging.info(f"Connecting to {self.source} (attempt {self._reconnect_attempts + 1})")
75
-
132
+
76
133
  if self.capture:
77
134
  self.capture.release()
78
-
79
- self.capture = cv2.VideoCapture(self._get_source_for_cv2())
80
-
135
+
136
+ self.capture = cv2.VideoCapture(self._get_source_for_cv2(), cv2.CAP_FFMPEG)
81
137
  if not self.capture.isOpened():
82
138
  logging.error(f"Failed to open video source: {self.source}")
83
139
  return False
84
-
140
+
85
141
  self._configure_capture()
86
142
  self.state = StreamState.CONNECTED
143
+
144
+ # Prime for the first decodable frame (bounded) and immediately step to steady-state
145
+ if not self._prime_until_keyframe(timeout=2.0, max_attempts=30):
146
+ logging.warning("Connected but no initial frame within 2s (waiting for keyframe).")
147
+
87
148
  return True
88
-
149
+
89
150
  except Exception as e:
90
151
  logging.error(f"Error initializing capture: {e}")
91
152
  self._cleanup_capture()
92
153
  return False
93
-
94
- def _configure_capture(self):
95
- """Configure capture properties and determine FPS."""
154
+
155
+ def _configure_capture(self) -> None:
156
+ # minimal buffering
157
+ try:
158
+ self.capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
159
+ except Exception:
160
+ pass
161
+
162
+ # optional timeouts (ignored if unsupported)
163
+ for prop, val in [
164
+ (getattr(cv2, "CAP_PROP_OPEN_TIMEOUT_MSEC", None), 4000),
165
+ (getattr(cv2, "CAP_PROP_READ_TIMEOUT_MSEC", None), 3000),
166
+ ]:
167
+ if prop is not None:
168
+ try:
169
+ self.capture.set(prop, val)
170
+ except Exception:
171
+ pass
172
+
173
+ # optional HW accel (ignored if unsupported)
174
+ try:
175
+ if hasattr(cv2, "CAP_PROP_HW_ACCELERATION"):
176
+ self.capture.set(cv2.CAP_PROP_HW_ACCELERATION, cv2.VIDEO_ACCELERATION_ANY)
177
+ except Exception:
178
+ pass
179
+
180
+ # fourcc/codec log
181
+ try:
182
+ fourcc = int(self.capture.get(cv2.CAP_PROP_FOURCC))
183
+ if fourcc:
184
+ self._codec_info = "".join([chr((fourcc >> (8 * i)) & 0xFF) for i in range(4)])
185
+ logging.info(f"Stream codec: {self._codec_info} (fourcc: {fourcc})")
186
+ except Exception:
187
+ pass
188
+
96
189
  detected_fps = self.capture.get(cv2.CAP_PROP_FPS)
97
-
98
- # Configure error-resilient settings for video streams
99
- if not self.is_file:
100
- try:
101
- # Set buffer size to reduce latency and handle corrupted frames better
102
- self.capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
103
-
104
- # Set codec-specific properties for better error handling
105
- # These help with HEVC streams that have QP delta and POC reference errors
106
- if hasattr(cv2, 'CAP_PROP_FOURCC'):
107
- # Try to get current codec
108
- fourcc = self.capture.get(cv2.CAP_PROP_FOURCC)
109
- codec_str = "".join([chr((int(fourcc) >> 8 * i) & 0xFF) for i in range(4)])
110
-
111
- # Log codec information for debugging
112
- logging.info(f"Stream codec detected: {codec_str} (fourcc: {int(fourcc)})")
113
- self._codec_info = codec_str
114
-
115
- # For HEVC streams, we can't change the codec but we can optimize buffering
116
- if 'hevc' in codec_str.lower() or 'h265' in codec_str.lower():
117
- logging.info("HEVC stream detected - applying error-resilient settings")
118
- # Reduce buffer to minimize impact of corrupted frames
119
- self.capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
120
-
121
- except Exception as e:
122
- logging.warning(f"Could not configure capture properties: {e}")
123
-
124
- if self.target_fps:
125
- self.fps = self.target_fps
126
- if hasattr(cv2, 'CAP_PROP_FPS'):
127
- self.capture.set(cv2.CAP_PROP_FPS, self.target_fps)
128
- elif detected_fps and 0 < detected_fps <= 240:
190
+ if detected_fps and 0 < detected_fps <= 240:
129
191
  self.fps = detected_fps
130
192
  else:
131
193
  self.fps = 30.0
132
- logging.warning(f"Invalid FPS detected ({detected_fps}), using {self.fps}")
133
-
194
+ logging.warning(f"Unknown/invalid FPS ({detected_fps}); defaulting to {self.fps}")
195
+
134
196
  if self.is_file:
135
- total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
136
- duration = total_frames / self.fps if self.fps > 0 else 0
137
- logging.info(f"Video file: {self.fps:.1f} FPS, {int(total_frames)} frames, {duration:.1f}s")
197
+ total = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
198
+ dur = total / self.fps if self.fps > 0 else 0
199
+ logging.info(f"Video file: {self.fps:.1f} FPS, {int(total)} frames, {dur:.1f}s")
138
200
  else:
139
- logging.info(f"Stream connected: {self.fps:.1f} FPS")
140
-
141
- def _cleanup_capture(self):
142
- """Clean up capture resources."""
201
+ logging.info(f"Stream connected at ~{self.fps:.1f} FPS")
202
+
203
+ def _cleanup_capture(self) -> None:
143
204
  if self.capture:
144
205
  try:
145
206
  self.capture.release()
@@ -148,50 +209,77 @@ class VideoStream(threading.Thread):
148
209
  finally:
149
210
  self.capture = None
150
211
  self.state = StreamState.DISCONNECTED
151
-
212
+
213
+ def _sleep_interruptible(self, duration: float) -> bool:
214
+ end = time.perf_counter() + duration
215
+ while self._running and time.perf_counter() < end:
216
+ time.sleep(0.05)
217
+ return self._running
218
+
152
219
  def _handle_reconnection(self) -> bool:
153
- """Handle reconnection logic with backoff."""
154
220
  if self._reconnect_attempts >= self.max_reconnect_attempts:
155
221
  logging.error(f"Max reconnection attempts reached for {self.source}")
156
222
  return False
157
-
158
223
  self._reconnect_attempts += 1
159
224
  self.state = StreamState.RECONNECTING
160
- self._current_interval = min(self._current_interval * self.backoff_factor, 60.0)
161
-
225
+ self._current_interval = min(self._current_interval * self.backoff_factor, self.max_sleep_backoff)
162
226
  logging.warning(f"Reconnecting in {self._current_interval:.1f}s...")
163
227
  return self._sleep_interruptible(self._current_interval)
164
-
165
- def _sleep_interruptible(self, duration: float) -> bool:
166
- """Sleep with ability to interrupt on stop."""
167
- end_time = time.time() + duration
168
- while time.time() < end_time and self._running:
169
- time.sleep(0.1)
170
- return self._running
171
-
228
+
172
229
  def _handle_file_end(self) -> bool:
173
- """Handle video file reaching end."""
174
230
  if not self.is_file:
175
231
  return False
176
-
177
232
  try:
178
- current_pos = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
179
- total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
180
-
181
- if current_pos >= total_frames - 1:
182
- logging.info(f"Video file ended, restarting: {self.source}")
233
+ cur = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
234
+ total = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
235
+ if cur >= total - 1:
183
236
  self.capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
184
237
  return True
185
238
  except Exception as e:
186
239
  logging.error(f"Error handling file end: {e}")
187
-
188
240
  return False
189
-
190
- def run(self):
191
- """Main capture loop."""
192
- failure_count = 0
193
- frame_interval = 1.0 / self.fps
194
-
241
+
242
+ # publish freshest
243
+ def _publish_latest(self, frame: cv2.Mat) -> None:
244
+ with self._buffer_lock:
245
+ if self._active_buf == "a":
246
+ self._buf_b = frame
247
+ self._active_buf = "b"
248
+ else:
249
+ self._buf_a = frame
250
+ self._active_buf = "a"
251
+ with self._latest_frame_lock:
252
+ src = self._buf_b if self._active_buf == "b" else self._buf_a
253
+ self._latest_frame = None if src is None else src.copy()
254
+ if not self._first_frame_evt.is_set() and self._latest_frame is not None:
255
+ self._first_frame_evt.set()
256
+ self._last_frame_ts = time.perf_counter()
257
+
258
+ # bounded priming + immediate read()
259
+ def _prime_until_keyframe(self, *, timeout: float, max_attempts: int) -> bool:
260
+ deadline = time.perf_counter() + timeout
261
+ attempts = 0
262
+ got = False
263
+ while self._running and time.perf_counter() < deadline and attempts < max_attempts:
264
+ attempts += 1
265
+ if not self.capture.grab():
266
+ time.sleep(0.01)
267
+ continue
268
+ ok, frame = self.capture.retrieve()
269
+ if ok and frame is not None and frame.size > 0:
270
+ self._publish_latest(frame)
271
+ got = True
272
+ break
273
+ if got:
274
+ # immediately step into steady-state
275
+ ret, frame2 = self.capture.read()
276
+ if ret and frame2 is not None and frame2.size > 0:
277
+ self._publish_latest(frame2)
278
+ return got
279
+
280
+ # ----- main loop -----
281
+ def run(self) -> None:
282
+ failures = 0
195
283
  while self._running:
196
284
  try:
197
285
  if not self.capture or not self.capture.isOpened():
@@ -199,147 +287,136 @@ class VideoStream(threading.Thread):
199
287
  if not self._handle_reconnection():
200
288
  break
201
289
  continue
202
-
203
- failure_count = 0
290
+ # reset counters
291
+ failures = 0
204
292
  self._reconnect_attempts = 0
205
293
  self._current_interval = self.reconnect_interval
206
- frame_interval = 1.0 / self.fps
207
-
208
- start_time = time.time()
294
+
209
295
  ret, frame = self.capture.read()
210
-
296
+
211
297
  if not ret or frame is None or frame.size == 0:
212
298
  if self._handle_file_end():
213
299
  continue
214
-
215
- failure_count += 1
216
- if failure_count > self.max_failures:
217
- logging.error("Too many consecutive failures, reconnecting...")
300
+ failures += 1
301
+ if failures > self.max_failures:
302
+ logging.error("Too many consecutive read failures; reconnecting.")
218
303
  self._cleanup_capture()
219
- failure_count = 0
304
+ failures = 0
220
305
  continue
221
-
222
- if not self._sleep_interruptible(0.1):
306
+ if not self._sleep_interruptible(0.02):
223
307
  break
308
+ # watchdog: if we’re “connected” but making no progress
309
+ if self.state in (StreamState.CONNECTED, StreamState.RECONNECTING):
310
+ if self._last_frame_ts and (time.perf_counter() - self._last_frame_ts) > 2.5:
311
+ logging.warning("No new frames for 2.5s while CONNECTED; forcing reconnect.")
312
+ self._cleanup_capture()
224
313
  continue
225
-
226
- failure_count = 0
314
+
315
+ # success
316
+ failures = 0
227
317
  self.frame_count += 1
228
-
229
- with self._lock:
230
- if self._running:
231
- self._latest_frame = frame.copy()
232
-
233
- elapsed = time.time() - start_time
234
- sleep_time = max(0, frame_interval - elapsed)
235
- if sleep_time > 0 and not self._sleep_interruptible(sleep_time):
236
- break
237
-
318
+
319
+ # (optional) backlog drain — disabled by default to avoid freeze
320
+ if self._drain_backlog and not self.is_file and self._first_frame_evt.is_set():
321
+ # bounded single-drain example (do not loop aggressively)
322
+ grabbed = self.capture.grab()
323
+ if grabbed:
324
+ ok, last = self.capture.retrieve()
325
+ if ok and last is not None and last.size > 0:
326
+ frame = last
327
+
328
+ self._publish_latest(frame)
329
+
330
+ # watchdog: reconnect if no progress despite being connected
331
+ if self.state in (StreamState.CONNECTED, StreamState.RECONNECTING):
332
+ if self._last_frame_ts and (time.perf_counter() - self._last_frame_ts) > 2.5:
333
+ logging.warning("No new frames for 2.5s while CONNECTED; forcing reconnect.")
334
+ self._cleanup_capture()
335
+
238
336
  except cv2.error as e:
239
- error_msg = f"OpenCV error: {e}"
240
- logging.error(error_msg)
241
- self._add_error_to_history(error_msg)
337
+ msg = f"OpenCV error: {e}"
338
+ logging.error(msg)
339
+ self._add_error_to_history(msg)
242
340
  self._cleanup_capture()
243
- if not self._sleep_interruptible(1.0):
341
+ if not self._sleep_interruptible(0.5):
244
342
  break
245
-
343
+
246
344
  except Exception as e:
247
- error_msg = f"Unexpected error: {e}"
248
- logging.error(error_msg, exc_info=True)
249
- self._add_error_to_history(error_msg)
345
+ msg = f"Unexpected error: {e}"
346
+ logging.error(msg, exc_info=True)
347
+ self._add_error_to_history(msg)
250
348
  if not self._sleep_interruptible(self.reconnect_interval):
251
349
  break
252
-
350
+
253
351
  self._final_cleanup()
254
-
255
- def _final_cleanup(self):
256
- """Final resource cleanup."""
257
- self.state = StreamState.STOPPED
258
- self._cleanup_capture()
259
-
260
- with self._lock:
261
- self._latest_frame = None
262
-
263
- logging.info(f"VideoStream stopped: {self.source}")
264
-
352
+
353
+ # ----- public API -----
265
354
  def get_frame(self) -> Optional[cv2.Mat]:
266
- """Get the latest frame (thread-safe)."""
267
355
  if not self._running or self.state not in (StreamState.CONNECTED, StreamState.RECONNECTING):
268
356
  return None
269
-
270
- with self._lock:
271
- return self._latest_frame.copy() if self._latest_frame is not None else None
272
-
357
+ with self._latest_frame_lock:
358
+ return None if self._latest_frame is None else self._latest_frame.copy()
359
+
360
+ def wait_first_frame(self, timeout: float = 5.0) -> bool:
361
+ return self._first_frame_evt.wait(timeout)
362
+
273
363
  def is_connected(self) -> bool:
274
- """Check if stream is currently connected."""
275
364
  return self.state == StreamState.CONNECTED
276
-
365
+
277
366
  @property
278
367
  def running(self) -> bool:
279
- """Check if stream is currently running."""
280
368
  return self._running and self.state != StreamState.STOPPED
281
-
369
+
282
370
  def get_state(self) -> StreamState:
283
- """Get current stream state."""
284
371
  return self.state
285
-
372
+
286
373
  def is_video_ended(self) -> bool:
287
- """Check if video file has ended."""
288
374
  if not self.is_file or not self.capture:
289
375
  return False
290
-
291
376
  try:
292
- current_pos = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
293
- total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
294
- return current_pos >= total_frames - 1
377
+ cur = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
378
+ total = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
379
+ return cur >= total - 1
295
380
  except Exception:
296
381
  return False
297
-
298
- def stop(self, timeout: float = 5.0):
299
- """Stop the video stream gracefully."""
382
+
383
+ def stop(self, timeout: float = 5.0) -> None:
300
384
  if not self._running:
301
385
  return
302
-
303
386
  logging.info(f"Stopping VideoStream: {self.source}")
304
387
  self._running = False
305
-
306
- with self._lock:
307
- self._latest_frame = None
308
-
309
388
  if self.is_alive():
310
389
  self.join(timeout=timeout)
311
390
  if self.is_alive():
312
391
  logging.warning(f"Stream thread did not exit within {timeout}s")
313
-
392
+
314
393
  def __enter__(self):
315
- """Context manager entry."""
316
394
  self.start()
317
395
  return self
318
-
396
+
319
397
  def __exit__(self, exc_type, exc_val, exc_tb):
320
- """Context manager exit."""
321
398
  self.stop()
322
-
323
- def _add_error_to_history(self, error_msg: str):
324
- """Add error to recent error history for diagnostics."""
325
- with self._lock:
326
- self._recent_errors.append({
327
- 'timestamp': time.time(),
328
- 'error': error_msg
329
- })
330
- # Keep only recent errors
331
- if len(self._recent_errors) > self._max_error_history:
332
- self._recent_errors = self._recent_errors[-self._max_error_history:]
333
-
334
- def get_recent_errors(self, max_age_seconds: float = 300) -> list:
335
- """Get recent errors within the specified time window."""
336
- current_time = time.time()
337
- with self._lock:
338
- return [
339
- err for err in self._recent_errors
340
- if current_time - err['timestamp'] <= max_age_seconds
341
- ]
342
-
399
+
400
+ # ----- teardown & diagnostics -----
401
+ def _final_cleanup(self) -> None:
402
+ self.state = StreamState.STOPPED
403
+ self._cleanup_capture()
404
+ with self._latest_frame_lock:
405
+ self._latest_frame = None
406
+ with self._buffer_lock:
407
+ self._buf_a = None
408
+ self._buf_b = None
409
+ logging.info(f"VideoStream stopped: {self.source}")
410
+
411
+ def _add_error_to_history(self, error_msg: str) -> None:
412
+ t = time.time()
413
+ self._recent_errors.append({"timestamp": t, "error": error_msg})
414
+ if len(self._recent_errors) > self._max_error_history:
415
+ self._recent_errors = self._recent_errors[-self._max_error_history:]
416
+
417
+ def get_recent_errors(self, max_age_seconds: float = 300) -> List[Dict[str, Union[str, float]]]:
418
+ now = time.time()
419
+ return [e for e in self._recent_errors if now - e["timestamp"] <= max_age_seconds]
420
+
343
421
  def get_codec_info(self) -> Optional[str]:
344
- """Get codec information for the stream."""
345
422
  return self._codec_info