Implement dual output for camera using QVideoWidget and QVideoSink, add camera permission handling, and enhance overlay error messaging
This commit is contained in:
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from PySide6.QtCore import QTimer
|
from PySide6.QtCore import QTimer
|
||||||
|
from PySide6.QtMultimedia import QVideoSink
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from duck_preview.camera.service import CameraService
|
from duck_preview.camera.service import CameraService
|
||||||
@@ -18,18 +19,22 @@ def main() -> None:
|
|||||||
app.setApplicationName("Duck Preview")
|
app.setApplicationName("Duck Preview")
|
||||||
|
|
||||||
camera = CameraService()
|
camera = CameraService()
|
||||||
|
video_sink = QVideoSink()
|
||||||
dispatcher = FrameDispatcher()
|
dispatcher = FrameDispatcher()
|
||||||
telemetry = TelemetryCollector()
|
telemetry = TelemetryCollector()
|
||||||
video_widget = VideoWidget()
|
video_widget = VideoWidget()
|
||||||
overlay = OverlayWidget()
|
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(telemetry.on_frame)
|
||||||
dispatcher.subscribe(video_widget.on_frame)
|
|
||||||
|
|
||||||
window = MainWindow(camera, video_widget, overlay)
|
window = MainWindow(camera, video_widget, overlay)
|
||||||
|
|
||||||
|
camera.error_occurred.connect(lambda msg: overlay.set_metrics({"error": msg}))
|
||||||
|
|
||||||
metrics_timer = QTimer()
|
metrics_timer = QTimer()
|
||||||
metrics_timer.timeout.connect(lambda: overlay.set_metrics(telemetry.metrics()))
|
metrics_timer.timeout.connect(lambda: overlay.set_metrics(telemetry.metrics()))
|
||||||
metrics_timer.start(200)
|
metrics_timer.start(200)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from PySide6.QtMultimedia import (
|
|||||||
QMediaDevices,
|
QMediaDevices,
|
||||||
QVideoSink,
|
QVideoSink,
|
||||||
)
|
)
|
||||||
|
from PySide6.QtMultimediaWidgets import QVideoWidget
|
||||||
|
|
||||||
|
|
||||||
class CameraService(QObject):
|
class CameraService(QObject):
|
||||||
@@ -17,18 +18,18 @@ class CameraService(QObject):
|
|||||||
def __init__(self, parent: QObject | None = None) -> None:
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._session = QMediaCaptureSession()
|
self._session = QMediaCaptureSession()
|
||||||
self._sink = QVideoSink()
|
|
||||||
self._session.setVideoOutput(self._sink)
|
|
||||||
self._camera: QCamera | None = None
|
self._camera: QCamera | None = None
|
||||||
|
|
||||||
@property
|
|
||||||
def sink(self) -> QVideoSink:
|
|
||||||
return self._sink
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def session(self) -> QMediaCaptureSession:
|
def session(self) -> QMediaCaptureSession:
|
||||||
return self._session
|
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
|
@staticmethod
|
||||||
def available_cameras() -> list[QCameraDevice]:
|
def available_cameras() -> list[QCameraDevice]:
|
||||||
return QMediaDevices.videoInputs()
|
return QMediaDevices.videoInputs()
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import QCameraPermission, Qt
|
||||||
from PySide6.QtGui import QAction, QCloseEvent
|
from PySide6.QtGui import QAction, QCloseEvent
|
||||||
from PySide6.QtMultimedia import QCameraDevice, QMediaDevices
|
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.camera.service import CameraService
|
||||||
from duck_preview.rendering.overlay import OverlayWidget
|
from duck_preview.rendering.overlay import OverlayWidget
|
||||||
@@ -70,8 +71,28 @@ class MainWindow(QMainWindow):
|
|||||||
self._camera_menu.addAction(action)
|
self._camera_menu.addAction(action)
|
||||||
|
|
||||||
def _on_camera_selected(self, device: QCameraDevice) -> None:
|
def _on_camera_selected(self, device: QCameraDevice) -> None:
|
||||||
self._camera.start(device)
|
self._request_camera_permission(device)
|
||||||
self._rebuild_resolution_menu(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:
|
def _rebuild_resolution_menu(self, device: QCameraDevice) -> None:
|
||||||
self._resolution_menu.clear()
|
self._resolution_menu.clear()
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ class OverlayWidget(QWidget):
|
|||||||
self.setAttribute(Qt.WA_TransparentForMouseEvents)
|
self.setAttribute(Qt.WA_TransparentForMouseEvents)
|
||||||
self.setAttribute(Qt.WA_NoSystemBackground)
|
self.setAttribute(Qt.WA_NoSystemBackground)
|
||||||
self._visible = True
|
self._visible = True
|
||||||
self._metrics: dict[str, float | int] = {}
|
self._metrics: dict[str, float | int | str] = {}
|
||||||
|
|
||||||
def set_visible(self, visible: bool) -> None:
|
def set_visible(self, visible: bool) -> None:
|
||||||
self._visible = visible
|
self._visible = visible
|
||||||
self.update()
|
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._metrics = metrics
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
@@ -25,16 +25,22 @@ class OverlayWidget(QWidget):
|
|||||||
if not self._visible or not self._metrics:
|
if not self._visible or not self._metrics:
|
||||||
return
|
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 = [
|
lines = [
|
||||||
f"FPS: {self._metrics.get('fps', 0):>7}",
|
f"FPS: {self._metrics.get('fps', 0):>7}",
|
||||||
f"Frame: {self._metrics.get('frame_time_ms', 0):>7.1f} ms",
|
f"Frame: {self._metrics.get('frame_time_ms', 0):>7.1f} ms",
|
||||||
f"Frames: {self._metrics.get('frame_count', 0):>7}",
|
f"Frames: {self._metrics.get('frame_count', 0):>7}",
|
||||||
]
|
]
|
||||||
|
|
||||||
painter = QPainter(self)
|
font = QFont("monospace", 11)
|
||||||
painter.setRenderHint(QPainter.TextAntialiasing)
|
|
||||||
|
|
||||||
font = QFont("Consolas", 11)
|
|
||||||
painter.setFont(font)
|
painter.setFont(font)
|
||||||
fm = QFontMetrics(font)
|
fm = QFontMetrics(font)
|
||||||
|
|
||||||
@@ -42,8 +48,26 @@ class OverlayWidget(QWidget):
|
|||||||
text_height = len(lines) * (fm.height() + 6) + 12
|
text_height = len(lines) * (fm.height() + 6) + 12
|
||||||
|
|
||||||
painter.fillRect(8, 8, text_width, text_height, QColor(0, 0, 0, 160))
|
painter.fillRect(8, 8, text_width, text_height, QColor(0, 0, 0, 160))
|
||||||
|
|
||||||
painter.setPen(QColor(0, 255, 0))
|
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):
|
for i, line in enumerate(lines):
|
||||||
y = 22 + i * (fm.height() + 6)
|
y = 22 + i * (fm.height() + 6)
|
||||||
painter.drawText(16, y, line)
|
painter.drawText(16, y, line)
|
||||||
|
|||||||
@@ -1,39 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtMultimediaWidgets import QVideoWidget
|
||||||
from PySide6.QtGui import QColor, QImage, QPainter
|
|
||||||
from PySide6.QtMultimedia import QVideoFrame
|
|
||||||
from PySide6.QtWidgets import QWidget
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
|
|
||||||
class VideoWidget(QWidget):
|
class VideoWidget(QVideoWidget):
|
||||||
def __init__(self, parent: QWidget | None = None) -> None:
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._frame: QImage | None = None
|
|
||||||
self.setAttribute(Qt.WA_OpaquePaintEvent)
|
|
||||||
self.setMinimumSize(320, 240)
|
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")
|
|
||||||
|
|||||||
212
notes/02-mvp-mac-plan.md
Normal file
212
notes/02-mvp-mac-plan.md
Normal file
@@ -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)
|
||||||
|
```
|
||||||
126
notes/02-mvp-mac.md
Normal file
126
notes/02-mvp-mac.md
Normal file
@@ -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"})`
|
||||||
6
pyside6-deploy.toml
Normal file
6
pyside6-deploy.toml
Normal file
@@ -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"]
|
||||||
Reference in New Issue
Block a user