# 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