213 lines
6.3 KiB
Markdown
213 lines
6.3 KiB
Markdown
# 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)
|
|
```
|