diff --git a/duck_preview/app.py b/duck_preview/app.py index dac8e17..49d3288 100644 --- a/duck_preview/app.py +++ b/duck_preview/app.py @@ -3,6 +3,7 @@ from __future__ import annotations import sys from PySide6.QtCore import QTimer +from PySide6.QtMultimedia import QVideoSink from PySide6.QtWidgets import QApplication from duck_preview.camera.service import CameraService @@ -18,18 +19,22 @@ def main() -> None: app.setApplicationName("Duck Preview") camera = CameraService() + video_sink = QVideoSink() dispatcher = FrameDispatcher() telemetry = TelemetryCollector() video_widget = VideoWidget() overlay = OverlayWidget() - camera.sink.videoFrameChanged.connect(dispatcher.on_frame) + camera.set_video_widget(video_widget) + camera.set_video_sink(video_sink) + video_sink.videoFrameChanged.connect(dispatcher.on_frame) dispatcher.subscribe(telemetry.on_frame) - dispatcher.subscribe(video_widget.on_frame) window = MainWindow(camera, video_widget, overlay) + camera.error_occurred.connect(lambda msg: overlay.set_metrics({"error": msg})) + metrics_timer = QTimer() metrics_timer.timeout.connect(lambda: overlay.set_metrics(telemetry.metrics())) metrics_timer.start(200) diff --git a/duck_preview/camera/service.py b/duck_preview/camera/service.py index 2b31eb3..3b94b7a 100644 --- a/duck_preview/camera/service.py +++ b/duck_preview/camera/service.py @@ -9,6 +9,7 @@ from PySide6.QtMultimedia import ( QMediaDevices, QVideoSink, ) +from PySide6.QtMultimediaWidgets import QVideoWidget class CameraService(QObject): @@ -17,18 +18,18 @@ class CameraService(QObject): def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) self._session = QMediaCaptureSession() - self._sink = QVideoSink() - self._session.setVideoOutput(self._sink) self._camera: QCamera | None = None - @property - def sink(self) -> QVideoSink: - return self._sink - @property def session(self) -> QMediaCaptureSession: return self._session + def set_video_widget(self, widget: QVideoWidget) -> None: + self._session.setVideoOutput(widget) + + def set_video_sink(self, sink: QVideoSink) -> None: + self._session.setVideoSink(sink) + @staticmethod def available_cameras() -> list[QCameraDevice]: return QMediaDevices.videoInputs() diff --git a/duck_preview/main_window.py b/duck_preview/main_window.py index 2712e9b..43f5462 100644 --- a/duck_preview/main_window.py +++ b/duck_preview/main_window.py @@ -1,8 +1,9 @@ from __future__ import annotations +from PySide6.QtCore import QCameraPermission, Qt from PySide6.QtGui import QAction, QCloseEvent from PySide6.QtMultimedia import QCameraDevice, QMediaDevices -from PySide6.QtWidgets import QGridLayout, QMainWindow, QWidget +from PySide6.QtWidgets import QApplication, QGridLayout, QMainWindow, QWidget from duck_preview.camera.service import CameraService from duck_preview.rendering.overlay import OverlayWidget @@ -70,8 +71,28 @@ class MainWindow(QMainWindow): self._camera_menu.addAction(action) def _on_camera_selected(self, device: QCameraDevice) -> None: - self._camera.start(device) - self._rebuild_resolution_menu(device) + 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) def _rebuild_resolution_menu(self, device: QCameraDevice) -> None: self._resolution_menu.clear() diff --git a/duck_preview/rendering/overlay.py b/duck_preview/rendering/overlay.py index 96fff05..343e5ff 100644 --- a/duck_preview/rendering/overlay.py +++ b/duck_preview/rendering/overlay.py @@ -11,13 +11,13 @@ class OverlayWidget(QWidget): self.setAttribute(Qt.WA_TransparentForMouseEvents) self.setAttribute(Qt.WA_NoSystemBackground) self._visible = True - self._metrics: dict[str, float | int] = {} + self._metrics: dict[str, float | int | str] = {} def set_visible(self, visible: bool) -> None: self._visible = visible self.update() - def set_metrics(self, metrics: dict[str, float | int]) -> None: + def set_metrics(self, metrics: dict[str, float | int | str]) -> None: self._metrics = metrics self.update() @@ -25,16 +25,22 @@ class OverlayWidget(QWidget): if not self._visible or not self._metrics: return + painter = QPainter(self) + painter.setRenderHint(QPainter.TextAntialiasing) + + if "error" in self._metrics: + self._paint_error(painter) + else: + self._paint_metrics(painter) + + def _paint_metrics(self, painter: QPainter) -> None: lines = [ f"FPS: {self._metrics.get('fps', 0):>7}", f"Frame: {self._metrics.get('frame_time_ms', 0):>7.1f} ms", f"Frames: {self._metrics.get('frame_count', 0):>7}", ] - painter = QPainter(self) - painter.setRenderHint(QPainter.TextAntialiasing) - - font = QFont("Consolas", 11) + font = QFont("monospace", 11) painter.setFont(font) fm = QFontMetrics(font) @@ -42,8 +48,26 @@ class OverlayWidget(QWidget): text_height = len(lines) * (fm.height() + 6) + 12 painter.fillRect(8, 8, text_width, text_height, QColor(0, 0, 0, 160)) - painter.setPen(QColor(0, 255, 0)) + + for i, line in enumerate(lines): + y = 22 + i * (fm.height() + 6) + painter.drawText(16, y, line) + + def _paint_error(self, painter: QPainter) -> None: + msg = str(self._metrics.get("error", "Unknown error")) + lines = msg.split("\n") + + font = QFont("monospace", 12) + painter.setFont(font) + fm = QFontMetrics(font) + + text_width = max(fm.horizontalAdvance(line) for line in lines) + 24 + text_height = len(lines) * (fm.height() + 6) + 12 + + painter.fillRect(8, 8, text_width, text_height, QColor(0, 0, 0, 200)) + painter.setPen(QColor(200, 50, 50)) + for i, line in enumerate(lines): y = 22 + i * (fm.height() + 6) painter.drawText(16, y, line) diff --git a/duck_preview/rendering/video_widget.py b/duck_preview/rendering/video_widget.py index f659c7c..a48597a 100644 --- a/duck_preview/rendering/video_widget.py +++ b/duck_preview/rendering/video_widget.py @@ -1,39 +1,10 @@ from __future__ import annotations -from PySide6.QtCore import Qt -from PySide6.QtGui import QColor, QImage, QPainter -from PySide6.QtMultimedia import QVideoFrame +from PySide6.QtMultimediaWidgets import QVideoWidget from PySide6.QtWidgets import QWidget -class VideoWidget(QWidget): +class VideoWidget(QVideoWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) - self._frame: QImage | None = None - self.setAttribute(Qt.WA_OpaquePaintEvent) self.setMinimumSize(320, 240) - - def on_frame(self, frame: QVideoFrame) -> None: - image = frame.toImage() - if not image.isNull(): - self._frame = image - self.update() - - def paintEvent(self, event) -> None: # noqa: N802 - painter = QPainter(self) - painter.setRenderHint(QPainter.SmoothPixmapTransform) - - if self._frame is not None: - painter.fillRect(self.rect(), QColor(0, 0, 0)) - scaled = self._frame.scaled( - self.size(), - Qt.KeepAspectRatio, - Qt.SmoothTransformation, - ) - x = (self.width() - scaled.width()) // 2 - y = (self.height() - scaled.height()) // 2 - painter.drawImage(x, y, scaled) - else: - painter.fillRect(self.rect(), QColor(20, 20, 20)) - painter.setPen(QColor(100, 100, 100)) - painter.drawText(self.rect(), Qt.AlignCenter, "No camera feed") diff --git a/notes/02-mvp-mac-plan.md b/notes/02-mvp-mac-plan.md new file mode 100644 index 0000000..1f0dc9c --- /dev/null +++ b/notes/02-mvp-mac-plan.md @@ -0,0 +1,212 @@ +# 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) +``` diff --git a/notes/02-mvp-mac.md b/notes/02-mvp-mac.md new file mode 100644 index 0000000..8c5f2d9 --- /dev/null +++ b/notes/02-mvp-mac.md @@ -0,0 +1,126 @@ +# macOS — Camera on macOS with PySide6 + +## Problem + +Aplikacja uruchomiona w interpreted mode (`python -m duck_preview`) na macOS z kamerą Elgato nie wyświetla obrazu. + +## Przyczyna + +Oficjalny przykład PySide6 (`camera.qml` / examples/multimedia) zawiera kod: + +```python +if sys.platform == "darwin": + is_nuitka = "__compiled__" in globals() + if not is_nuitka and sys.platform == "darwin": + print("This example does not work on macOS when Python is run " + "in interpreted mode. For this example to work on macOS, " + "package the example using pyside6-deploy") + sys.exit(0) +``` + +**macOS → AVFoundation → wymaga spakowania przez Nuitka/pyside6-deploy.** + +`QCamera` na macOS potrzebuje properly bundled app structure (Info.plist, entitlements, code signing). W interpreted mode Python nie ma tego contextu. + +## Rozwiązanie + +Pakować aplikację przez `pyside6-deploy` (Nuitka) na macOS. Na Windows działa bez pakowania. + +--- + +## Qt Permission API (`QCameraPermission`) + +Od Qt 6.5+ dostępne jest `QCameraPermission` — nowoczesne API do proszenia o zgodę kamery. + +### API + +```python +from PySide6.QtCore import QCameraPermission, Qt +from PySide6.QtWidgets import QApplication + +perm = QCameraPermission() +match QApplication.checkPermission(perm): + case Qt.PermissionStatus.Undetermined: + # Prosimy o zgodę → callback wywoła init ponownie + QApplication.requestPermission(perm, parent, callback) + return + case Qt.PermissionStatus.Denied: + # overlay: "Camera permission denied" + case Qt.PermissionStatus.Granted: + # uruchom kamerę +``` + +### Permission flow + +``` +User → wybiera kamerę w menu + → MainWindow._on_camera_selected(device) + → checkPermission(QCameraPermission) + ├─ Undetermined → requestPermission(camera, parent, callback) + │ └─ callback → _on_camera_selected ponownie + ├─ Denied → overlay: "Camera permission denied. Grant access in System Settings > Privacy & Security > Camera" + └─ Granted → CameraService.start(device) +``` + +--- + +## QVideoWidget + QVideoSink — dual output + +PySide6 6.11 ma osobne metody w `QMediaCaptureSession`: + +| Metoda | Przeznaczenie | +|--------|---------------| +| `setVideoOutput(QVideoWidget*)` | Natywne renderowanie (GPU) | +| `setVideoSink(QVideoSink*)` | Dostęp do klatek (telemetria, AI) | + +Oba działają **równolegle** — nie ma konfliktu. + +``` +QMediaCaptureSession + ├─ setVideoOutput(QVideoWidget) → natywne renderowanie + └─ setVideoSink(QVideoSink) → frame access + └─ videoFrameChanged → FrameDispatcher +``` + +QVideoSink dostarcza sygnał `videoFrameChanged(QVideoFrame)` — na to podpina się FrameDispatcher, a ten rozsyła do TelemetryCollector i przyszłych AI subscriberów. + +--- + +## macOS — packaging + +### pyside6-deploy + +```bash +pip install nuitka +pyside6-deploy duck_preview/__main__.py --name "Duck Preview" +``` + +### 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"] +``` + +Po spakowaniu powstaje `Duck Preview.app` — standalone bundle z dostępem do AVFoundation. + +### Windows + +Na Windows działa bez pakowania — `python -m duck_preview` w venv. + +--- + +## Font fallback + +`Consolas` nie istnieje na macOS. Używać `"monospace"` (generic font family — Qt mapuje na Menlo na macOS, Consolas na Windows). + +## Error state w Overlay + +Overlay wspiera wyświetlanie błędów (np. brak permisji): +- Normalny stan → zielone metryki (FPS, frame times) +- Error state → czerwony komunikat +- Przełączanie przez `overlay.set_metrics({"error": "message"})` diff --git a/pyside6-deploy.toml b/pyside6-deploy.toml new file mode 100644 index 0000000..49a3521 --- /dev/null +++ b/pyside6-deploy.toml @@ -0,0 +1,6 @@ +[app] +script = "duck_preview/__main__.py" +name = "Duck Preview" +bundle_identifier = "com.bartool.duck-preview" +categories = "public.app-category.photography" +platforms = ["macos"]