# Plan — macOS fix + QVideoWidget + QVideoSink dual output ## Cel Przywrócić działanie kamery na macOS (Elgato) przez: 1. Przejście z manualnego `QPainter` renderowania na natywny `QVideoWidget` 2. Dodanie `QVideoSink` jako drugiego wyjścia (frame access dla telemetrii) 3. Obsługa `QCameraPermission` (Qt 6.5+) 4. Dokumentacja packagingu przez `pyside6-deploy` na macOS ## Kolejność implementacji ### 1. camera/service.py ```python class CameraService(QObject): error_occurred = Signal(str) def __init__(self, parent=None): super().__init__(parent) self._session = QMediaCaptureSession() self._camera: QCamera | None = None def set_video_widget(self, widget: QVideoWidget) -> None: self._session.setVideoOutput(widget) def set_video_sink(self, sink: QVideoSink) -> None: self._session.setVideoSink(sink) @property def session(self) -> QMediaCaptureSession: return self._session @staticmethod def available_cameras() -> list[QCameraDevice]: return QMediaDevices.videoInputs() def is_active(self) -> bool: return self._camera is not None and self._camera.isActive() def start(self, device: QCameraDevice) -> None: self.stop() self._camera = QCamera(device, self) self._camera.errorOccurred.connect(self._on_error) self._session.setCamera(self._camera) self._camera.start() def stop(self) -> None: if self._camera is not None: self._camera.stop() self._session.setCamera(None) self._camera.deleteLater() self._camera = None def set_camera_format(self, fmt: QCameraFormat) -> None: if self._camera is not None: self._camera.setCameraFormat(fmt) def _on_error(self, error, error_string): self.error_occurred.emit(error_string) ``` Zmiany: - Usunięto `self._sink` z `__init__` - Usunięto `self._session.setVideoOutput(self._sink)` - Dodano `set_video_widget(widget)` → `session.setVideoOutput(widget)` - Dodano `set_video_sink(sink)` → `session.setVideoSink(sink)` ### 2. rendering/video_widget.py ```python from __future__ import annotations from PySide6.QtMultimediaWidgets import QVideoWidget class VideoWidget(QVideoWidget): def __init__(self, parent=None): super().__init__(parent) self.setMinimumSize(320, 240) ``` Zmiany: - Dziedziczy po `QVideoWidget` zamiast `QWidget` - Usunięto `on_frame(frame)` — nie potrzeba, renderowanie natywne - Usunięto `paintEvent` — nie potrzeba, robi to Qt - Usunięto `QImage`, `QPainter`, `SmoothPixmapTransform` ### 3. rendering/overlay.py ```python font = QFont("monospace", 11) # zamiast "Consolas" ``` Dodatkowo: obsługa błędu w `paintEvent`: - Jeśli `self._metrics` ma klucz `"error"` → pomiń normalne metryki, narysuj czerwony komunikat błędu - Użyj `QColor(180, 40, 40)` zamiast zielonego ### 4. app.py — nowe DI wiring ```python def main(): app = QApplication(sys.argv) app.setApplicationName("Duck Preview") camera = CameraService() video_sink = QVideoSink() dispatcher = FrameDispatcher() telemetry = TelemetryCollector() video_widget = VideoWidget() overlay = OverlayWidget() camera.set_video_widget(video_widget) camera.set_video_sink(video_sink) video_sink.videoFrameChanged.connect(dispatcher.on_frame) dispatcher.subscribe(telemetry.on_frame) window = MainWindow(camera, video_widget, overlay) # Wire error → overlay camera.error_occurred.connect( lambda msg: overlay.set_metrics({"error": msg}) ) # Poll telemetry → overlay metrics_timer = QTimer() metrics_timer.timeout.connect( lambda: overlay.set_metrics(telemetry.metrics()) ) metrics_timer.start(200) window.show() sys.exit(app.exec()) ``` Zmiany: - Tworzy `QVideoSink` jawnie - `camera.set_video_widget(video_widget)` + `camera.set_video_sink(video_sink)` - `video_sink.videoFrameChanged → dispatcher.on_frame` - Usunięto `camera.sink` (property nie istnieje) - `camera.error_occurred → overlay.set_metrics({"error": msg})` ### 5. main_window.py — permission flow ```python # Importy from PySide6.QtCore import QCameraPermission, Qt from PySide6.QtWidgets import QApplication # W _on_camera_selected: def _on_camera_selected(self, device: QCameraDevice) -> None: self._request_camera_permission(device) def _request_camera_permission(self, device: QCameraDevice) -> None: perm = QCameraPermission() match QApplication.checkPermission(perm): case Qt.PermissionStatus.Undetermined: QApplication.requestPermission(perm, self, lambda: self._request_camera_permission(device)) case Qt.PermissionStatus.Denied: self._overlay.set_metrics({ "error": "Camera permission denied.\n" "Grant access in System Settings > " "Privacy & Security > Camera" }) case Qt.PermissionStatus.Granted: self._camera.start(device) self._rebuild_resolution_menu(device) ``` ### 6. pyside6-deploy.toml ```toml [app] script = "duck_preview/__main__.py" name = "Duck Preview" bundle_identifier = "com.bartool.duck-preview" categories = "public.app-category.photography" platforms = ["macos"] ``` ### 7. Po zmianach - `ruff check .` — czysty - `ruff format .` — sformatowany - `pytest -q` — 5 passed --- ## Podsumowanie architektury końcowej ``` CameraService ├─ session.setVideoOutput(VideoWidget) → natywne GPU rendering └─ session.setVideoSink(VideoSink) → frame access └─ videoFrameChanged → FrameDispatcher ├─ TelemetryCollector └─ [future AI] MainWindow ├─ _on_camera_selected │ → QCameraPermission check (Undetermined/Denied/Granted) │ → CameraService.start(device) ├─ error_occurred → OverlayWidget.set_metrics({"error": ...}) └─ QTimer 200ms → TelemetryCollector.metrics() → OverlayWidget OverlayWidget ├─ normal: zielone metryki (FPS, frame time, frame count) └─ error: czerwony komunikat (np. brak permisji) ```