Files
duck-preview/notes/02-mvp-mac-plan.md

6.3 KiB

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

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

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

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

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

# 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

[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)