"""Frame Dispatcher — distributes QVideoFrames to registered subscribers.""" from __future__ import annotations import logging import time from collections.abc import Callable from dataclasses import dataclass, field from PySide6.QtCore import QObject, Slot from PySide6.QtMultimedia import QVideoFrame logger = logging.getLogger(__name__) FrameCallback = Callable[[QVideoFrame], None] @dataclass class _Subscriber: callback: FrameCallback drop_if_busy: bool = True _busy: bool = field(default=False, init=False, repr=False) class FrameDispatcher(QObject): """ Receives frames from CameraService and fans them out to all subscribers. Each subscriber is a callable (QVideoFrame) -> None. Subscribers that set drop_if_busy=True will skip a frame if they are still processing the previous one (non-blocking). Subscribers with drop_if_busy=False always receive every frame. All dispatch happens in the GUI thread (Qt signal/slot), so subscribers must NOT perform heavy work directly — they should queue to a worker thread if processing is needed. """ def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) self._subscribers: list[_Subscriber] = [] self._frame_count: int = 0 self._last_dispatch_time: float = 0.0 # ------------------------------------------------------------------ # Subscription API # ------------------------------------------------------------------ def subscribe(self, callback: FrameCallback, *, drop_if_busy: bool = True) -> None: """Register a frame callback. Args: callback: Callable that receives QVideoFrame. drop_if_busy: When True, frame is skipped if subscriber is still marked busy from last call (default True). """ for sub in self._subscribers: if sub.callback is callback: logger.warning("Subscriber %r already registered", callback) return self._subscribers.append(_Subscriber(callback=callback, drop_if_busy=drop_if_busy)) logger.debug("Subscriber added: %r (drop_if_busy=%s)", callback, drop_if_busy) def unsubscribe(self, callback: FrameCallback) -> None: """Remove a previously registered callback.""" before = len(self._subscribers) self._subscribers = [s for s in self._subscribers if s.callback is not callback] if len(self._subscribers) < before: logger.debug("Subscriber removed: %r", callback) else: logger.debug("Subscriber not found for removal: %r", callback) def subscriber_count(self) -> int: return len(self._subscribers) # ------------------------------------------------------------------ # Frame intake — connect CameraService.frame_ready to this slot # ------------------------------------------------------------------ @Slot(QVideoFrame) def dispatch(self, frame: QVideoFrame) -> None: """Distribute the frame to all registered subscribers.""" self._frame_count += 1 now = time.perf_counter() self._last_dispatch_time = now for sub in self._subscribers: if sub.drop_if_busy and sub._busy: logger.debug("Dropping frame for busy subscriber %r", sub.callback) continue sub._busy = True try: sub.callback(frame) except Exception: logger.exception("Error in frame subscriber %r", sub.callback) finally: sub._busy = False # ------------------------------------------------------------------ # Stats # ------------------------------------------------------------------ @property def frame_count(self) -> int: return self._frame_count @property def last_dispatch_time(self) -> float: """perf_counter timestamp of the last dispatched frame.""" return self._last_dispatch_time