Implement initial structure and core functionality for Duck Preview application
This commit is contained in:
150
notes/01-mvp-plan.md
Normal file
150
notes/01-mvp-plan.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# MVP Implementation Plan — Duck Preview
|
||||
|
||||
## Stack
|
||||
|
||||
- **Language:** Python 3.12
|
||||
- **GUI/Framework:** PySide6 6.11 (QtMultimedia + QtWidgets)
|
||||
- **Camera backend:** QCamera + QMediaCaptureSession + QVideoSink (native GPU)
|
||||
- **Rendering:** QWidget (paintEvent) z QPainter — manual render z QVideoFrame → QImage
|
||||
- **Testing:** pytest
|
||||
- **Linting/Formatting:** ruff
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
CameraService
|
||||
└─ QVideoSink.videoFrame ──→ FrameDispatcher.on_frame
|
||||
├─ VideoWidget.on_frame (render klatki)
|
||||
├─ TelemetryCollector.on_frame (timestamp)
|
||||
└─ [Future AI subscribers]
|
||||
|
||||
TelemetryCollector.metrics() ──→ OverlayWidget.set_metrics()
|
||||
(polled co 200ms przez QTimer w app.py)
|
||||
```
|
||||
|
||||
## Kolejność implementacji
|
||||
|
||||
### 0. Project scaffolding
|
||||
`pyproject.toml` — projekt, zależność PySide6, entry point, ruff config, pytest
|
||||
`duck_preview/__init__.py`, `__main__.py` — entry point `python -m duck_preview`
|
||||
Podkatalogi: `camera/`, `dispatcher/`, `rendering/`, `telemetry/`
|
||||
|
||||
### 1. CameraService (`camera/service.py`)
|
||||
- Wrapper na `QCamera` + `QMediaCaptureSession` + `QVideoSink`
|
||||
- `available_cameras()` — static, zwraca listę `QCameraDevice` z `QMediaDevices`
|
||||
- `start(device)` / `stop()` — zarządzanie cyklem życia kamery
|
||||
- `set_camera_format(fmt)` — zmiana rozdzielczości/FPS
|
||||
- `sink` property — QVideoSink do podpięcia dispatchera
|
||||
- `error_occurred` Signal — propagacja błędów kamery
|
||||
|
||||
### 2. FrameDispatcher (`dispatcher/frame_dispatcher.py`)
|
||||
- Prosty pub/sub: `subscribe(cb)` / `unsubscribe(cb)`
|
||||
- `on_frame(frame)` — wołany z sygnału QVideoSink, iteruje wszystkich subskrybentów
|
||||
- Frame dropping: na razie brak (wszystkie callbacki szybkie)
|
||||
|
||||
### 3. TelemetryCollector (`telemetry/collector.py`)
|
||||
- Subskrybent dispatchera
|
||||
- Zbiera timestampy (`deque`, maxlen=500)
|
||||
- `metrics()` → dict: fps (klatki z ostatniej sekundy), frame_time_ms (średnia delta między klatkami), frame_count
|
||||
- Brak CPU usage (out of scope na MVP)
|
||||
|
||||
### 4. VideoWidget (`rendering/video_widget.py`)
|
||||
- `QWidget` z `WA_OpaquePaintEvent`
|
||||
- `on_frame(frame)` — `frame.toImage()` → zapis QImage → `update()`
|
||||
- `paintEvent` — skalowanie z zachowaniem proporcji, centrowanie, letterboxing
|
||||
- Brak klatek → wyświetla "No camera feed"
|
||||
|
||||
### 5. OverlayWidget (`rendering/overlay.py`)
|
||||
- Przezroczysty QWidget (`WA_TransparentForMouseEvents`)
|
||||
- Nakładka na video, rysuje tekst metryk (FPS, frame time, frame count)
|
||||
- Semi-transparentne tło, zielona czcionka Consolas
|
||||
- Toggle widoczności przez menu Debug
|
||||
|
||||
### 6. MainWindow (`main_window.py`)
|
||||
- `QMainWindow` z menu barem:
|
||||
- **Camera** — lista dostępnych kamer (dynamiczna, reaguje na `videoInputsChanged`)
|
||||
- **Resolution** — dostępne rozdzielczości dla wybranej kamery
|
||||
- **FPS** — dostępne FPS dla wybranej rozdzielczości
|
||||
- **Debug** — toggle nakładki
|
||||
- Central widget: GridLayout z VideoWidget + OverlayWidget (stacked)
|
||||
|
||||
### 7. App (`app.py`)
|
||||
- `main()` — tworzy QApplication, instancjonuje i łączy zależności (ręczne DI)
|
||||
- Wire: camera.sink → dispatcher.on_frame
|
||||
- Wire: dispatcher → telemetry.on_frame, video_widget.on_frame
|
||||
- QTimer 200ms: telemetry.metrics() → overlay.set_metrics()
|
||||
- `__main__.py` → `from duck_preview.app import main; main()`
|
||||
|
||||
---
|
||||
|
||||
## DI Wiring (ręczne, w app.py)
|
||||
|
||||
```
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
camera = CameraService()
|
||||
dispatcher = FrameDispatcher()
|
||||
telemetry = TelemetryCollector()
|
||||
video_widget = VideoWidget()
|
||||
overlay = OverlayWidget()
|
||||
|
||||
camera.sink.videoFrame.connect(dispatcher.on_frame)
|
||||
|
||||
dispatcher.subscribe(telemetry.on_frame)
|
||||
dispatcher.subscribe(video_widget.on_frame)
|
||||
|
||||
window = MainWindow(camera, video_widget, overlay)
|
||||
|
||||
# Poll telemetry co 200ms → overlay
|
||||
metrics_timer = QTimer()
|
||||
metrics_timer.timeout.connect(lambda: overlay.set_metrics(telemetry.metrics()))
|
||||
metrics_timer.start(200)
|
||||
|
||||
window.show()
|
||||
app.exec()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Struktura plików
|
||||
|
||||
```
|
||||
duck-preview/
|
||||
├── pyproject.toml
|
||||
├── notes/
|
||||
│ ├── 01-mvp-preview.md
|
||||
│ └── 01-mvp-plan.md
|
||||
├── duck_preview/
|
||||
│ ├── __init__.py
|
||||
│ ├── __main__.py
|
||||
│ ├── app.py
|
||||
│ ├── main_window.py
|
||||
│ ├── camera/
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── service.py
|
||||
│ ├── dispatcher/
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── frame_dispatcher.py
|
||||
│ ├── rendering/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── video_widget.py
|
||||
│ │ └── overlay.py
|
||||
│ └── telemetry/
|
||||
│ ├── __init__.py
|
||||
│ └── collector.py
|
||||
└── tests/
|
||||
├── __init__.py
|
||||
├── test_dispatcher.py
|
||||
└── test_collector.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edge cases / uwagi
|
||||
|
||||
- Brak kamer → menu Camera pokazuje "No cameras detected"
|
||||
- Brak klatek → VideoWidget pokazuje ciemne tło + napis
|
||||
- Błąd kamery → CameraService emituje `error_occurred` (na razie tylko log)
|
||||
- Zamknięcie okna → `closeEvent` → `camera.stop()`
|
||||
- QVideoFrame.toImage() kopiuje dane — akceptowalne dla MVP
|
||||
- Wszystkie obiekty żyją w main thread — brak problemów z threadingiem
|
||||
Reference in New Issue
Block a user