Implement dual output for camera using QVideoWidget and QVideoSink, add camera permission handling, and enhance overlay error messaging
This commit is contained in:
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)
|
||||
```
|
||||
Reference in New Issue
Block a user