Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4916764264 | |||
| 58fff52d31 |
0
duck_preview/__init__.py
Normal file
0
duck_preview/__init__.py
Normal file
3
duck_preview/__main__.py
Normal file
3
duck_preview/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from duck_preview.app import main
|
||||||
|
|
||||||
|
main()
|
||||||
47
duck_preview/app.py
Normal file
47
duck_preview/app.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PySide6.QtCore import QTimer
|
||||||
|
from PySide6.QtMultimedia import QVideoSink
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from duck_preview.camera.service import CameraService
|
||||||
|
from duck_preview.dispatcher.frame_dispatcher import FrameDispatcher
|
||||||
|
from duck_preview.main_window import MainWindow
|
||||||
|
from duck_preview.rendering.overlay import OverlayWidget
|
||||||
|
from duck_preview.rendering.video_widget import VideoWidget
|
||||||
|
from duck_preview.telemetry.collector import TelemetryCollector
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
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)
|
||||||
|
|
||||||
|
camera.error_occurred.connect(lambda msg: overlay.set_metrics({"error": msg}))
|
||||||
|
|
||||||
|
metrics_timer = QTimer()
|
||||||
|
metrics_timer.timeout.connect(lambda: overlay.set_metrics(telemetry.metrics()))
|
||||||
|
metrics_timer.start(200)
|
||||||
|
|
||||||
|
window.show()
|
||||||
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
0
duck_preview/camera/__init__.py
Normal file
0
duck_preview/camera/__init__.py
Normal file
59
duck_preview/camera/service.py
Normal file
59
duck_preview/camera/service.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, Signal
|
||||||
|
from PySide6.QtMultimedia import (
|
||||||
|
QCamera,
|
||||||
|
QCameraDevice,
|
||||||
|
QCameraFormat,
|
||||||
|
QMediaCaptureSession,
|
||||||
|
QMediaDevices,
|
||||||
|
QVideoSink,
|
||||||
|
)
|
||||||
|
from PySide6.QtMultimediaWidgets import QVideoWidget
|
||||||
|
|
||||||
|
|
||||||
|
class CameraService(QObject):
|
||||||
|
error_occurred = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._session = QMediaCaptureSession()
|
||||||
|
self._camera: QCamera | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session(self) -> QMediaCaptureSession:
|
||||||
|
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
|
||||||
|
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: QCamera.Error, error_string: str) -> None:
|
||||||
|
self.error_occurred.emit(error_string)
|
||||||
0
duck_preview/dispatcher/__init__.py
Normal file
0
duck_preview/dispatcher/__init__.py
Normal file
22
duck_preview/dispatcher/frame_dispatcher.py
Normal file
22
duck_preview/dispatcher/frame_dispatcher.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject
|
||||||
|
from PySide6.QtMultimedia import QVideoFrame
|
||||||
|
|
||||||
|
|
||||||
|
class FrameDispatcher(QObject):
|
||||||
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._subscribers: list[Callable[[QVideoFrame], None]] = []
|
||||||
|
|
||||||
|
def subscribe(self, callback: Callable[[QVideoFrame], None]) -> None:
|
||||||
|
self._subscribers.append(callback)
|
||||||
|
|
||||||
|
def unsubscribe(self, callback: Callable[[QVideoFrame], None]) -> None:
|
||||||
|
self._subscribers.remove(callback)
|
||||||
|
|
||||||
|
def on_frame(self, frame: QVideoFrame) -> None:
|
||||||
|
for cb in self._subscribers:
|
||||||
|
cb(frame)
|
||||||
139
duck_preview/main_window.py
Normal file
139
duck_preview/main_window.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import QCameraPermission, Qt
|
||||||
|
from PySide6.QtGui import QAction, QCloseEvent
|
||||||
|
from PySide6.QtMultimedia import QCameraDevice, QMediaDevices
|
||||||
|
from PySide6.QtWidgets import QApplication, QGridLayout, QMainWindow, QWidget
|
||||||
|
|
||||||
|
from duck_preview.camera.service import CameraService
|
||||||
|
from duck_preview.rendering.overlay import OverlayWidget
|
||||||
|
from duck_preview.rendering.video_widget import VideoWidget
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
camera_service: CameraService,
|
||||||
|
video_widget: VideoWidget,
|
||||||
|
overlay_widget: OverlayWidget,
|
||||||
|
parent: QWidget | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._camera = camera_service
|
||||||
|
self._video_widget = video_widget
|
||||||
|
self._overlay = overlay_widget
|
||||||
|
self._media_devices = QMediaDevices()
|
||||||
|
|
||||||
|
self.setWindowTitle("Duck Preview")
|
||||||
|
self.resize(1280, 720)
|
||||||
|
|
||||||
|
central = QWidget()
|
||||||
|
self.setCentralWidget(central)
|
||||||
|
layout = QGridLayout(central)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
layout.addWidget(video_widget, 0, 0)
|
||||||
|
layout.addWidget(overlay_widget, 0, 0)
|
||||||
|
|
||||||
|
self._setup_menus()
|
||||||
|
|
||||||
|
self._media_devices.videoInputsChanged.connect(self._rebuild_camera_menu)
|
||||||
|
|
||||||
|
def _setup_menus(self) -> None:
|
||||||
|
menu_bar = self.menuBar()
|
||||||
|
|
||||||
|
self._camera_menu = menu_bar.addMenu("Camera")
|
||||||
|
self._rebuild_camera_menu()
|
||||||
|
|
||||||
|
self._resolution_menu = menu_bar.addMenu("Resolution")
|
||||||
|
self._resolution_menu.setEnabled(False)
|
||||||
|
|
||||||
|
self._fps_menu = menu_bar.addMenu("FPS")
|
||||||
|
self._fps_menu.setEnabled(False)
|
||||||
|
|
||||||
|
debug_menu = menu_bar.addMenu("Debug")
|
||||||
|
toggle_overlay = QAction("Show Metrics", self)
|
||||||
|
toggle_overlay.setCheckable(True)
|
||||||
|
toggle_overlay.setChecked(True)
|
||||||
|
toggle_overlay.triggered.connect(self._overlay.set_visible)
|
||||||
|
debug_menu.addAction(toggle_overlay)
|
||||||
|
|
||||||
|
def _rebuild_camera_menu(self) -> None:
|
||||||
|
self._camera_menu.clear()
|
||||||
|
cameras = CameraService.available_cameras()
|
||||||
|
for device in cameras:
|
||||||
|
action = QAction(device.description(), self)
|
||||||
|
action.triggered.connect(lambda checked, d=device: self._on_camera_selected(d))
|
||||||
|
self._camera_menu.addAction(action)
|
||||||
|
if not cameras:
|
||||||
|
action = QAction("No cameras detected", self)
|
||||||
|
action.setEnabled(False)
|
||||||
|
self._camera_menu.addAction(action)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def _rebuild_resolution_menu(self, device: QCameraDevice) -> None:
|
||||||
|
self._resolution_menu.clear()
|
||||||
|
self._resolution_menu.setEnabled(True)
|
||||||
|
formats = device.videoFormats()
|
||||||
|
seen: set[tuple[int, int]] = set()
|
||||||
|
for fmt in formats:
|
||||||
|
res = fmt.resolution()
|
||||||
|
key = (res.width(), res.height())
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
action = QAction(f"{res.width()}x{res.height()}", self)
|
||||||
|
action.triggered.connect(
|
||||||
|
lambda checked, d=device, w=res.width(), h=res.height(): (
|
||||||
|
self._on_resolution_selected(d, w, h)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._resolution_menu.addAction(action)
|
||||||
|
|
||||||
|
def _on_resolution_selected(self, device: QCameraDevice, width: int, height: int) -> None:
|
||||||
|
self._rebuild_fps_menu(device, width, height)
|
||||||
|
|
||||||
|
def _rebuild_fps_menu(self, device: QCameraDevice, width: int, height: int) -> None:
|
||||||
|
self._fps_menu.clear()
|
||||||
|
self._fps_menu.setEnabled(True)
|
||||||
|
formats = device.videoFormats()
|
||||||
|
seen_labels: set[str] = set()
|
||||||
|
for fmt in formats:
|
||||||
|
res = fmt.resolution()
|
||||||
|
if res.width() == width and res.height() == height:
|
||||||
|
min_fps = round(fmt.minFrameRate())
|
||||||
|
max_fps = round(fmt.maxFrameRate())
|
||||||
|
label = f"{max_fps} FPS" if min_fps == max_fps else f"{min_fps}-{max_fps} FPS"
|
||||||
|
if label not in seen_labels:
|
||||||
|
seen_labels.add(label)
|
||||||
|
action = QAction(label, self)
|
||||||
|
action.triggered.connect(
|
||||||
|
lambda checked, f=fmt: self._camera.set_camera_format(f)
|
||||||
|
)
|
||||||
|
self._fps_menu.addAction(action)
|
||||||
|
|
||||||
|
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
|
||||||
|
self._camera.stop()
|
||||||
|
super().closeEvent(event)
|
||||||
0
duck_preview/rendering/__init__.py
Normal file
0
duck_preview/rendering/__init__.py
Normal file
73
duck_preview/rendering/overlay.py
Normal file
73
duck_preview/rendering/overlay.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter
|
||||||
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
|
|
||||||
|
class OverlayWidget(QWidget):
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setAttribute(Qt.WA_TransparentForMouseEvents)
|
||||||
|
self.setAttribute(Qt.WA_NoSystemBackground)
|
||||||
|
self._visible = True
|
||||||
|
self._metrics: dict[str, float | int | str] = {}
|
||||||
|
|
||||||
|
def set_visible(self, visible: bool) -> None:
|
||||||
|
self._visible = visible
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def set_metrics(self, metrics: dict[str, float | int | str]) -> None:
|
||||||
|
self._metrics = metrics
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, event) -> None: # noqa: N802
|
||||||
|
if not self._visible or not self._metrics:
|
||||||
|
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 = [
|
||||||
|
f"FPS: {self._metrics.get('fps', 0):>7}",
|
||||||
|
f"Frame: {self._metrics.get('frame_time_ms', 0):>7.1f} ms",
|
||||||
|
f"Frames: {self._metrics.get('frame_count', 0):>7}",
|
||||||
|
]
|
||||||
|
|
||||||
|
font = QFont("monospace", 11)
|
||||||
|
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, 160))
|
||||||
|
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):
|
||||||
|
y = 22 + i * (fm.height() + 6)
|
||||||
|
painter.drawText(16, y, line)
|
||||||
10
duck_preview/rendering/video_widget.py
Normal file
10
duck_preview/rendering/video_widget.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtMultimediaWidgets import QVideoWidget
|
||||||
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
|
|
||||||
|
class VideoWidget(QVideoWidget):
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setMinimumSize(320, 240)
|
||||||
0
duck_preview/telemetry/__init__.py
Normal file
0
duck_preview/telemetry/__init__.py
Normal file
39
duck_preview/telemetry/collector.py
Normal file
39
duck_preview/telemetry/collector.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject
|
||||||
|
from PySide6.QtMultimedia import QVideoFrame
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryCollector(QObject):
|
||||||
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._timestamps: deque[float] = deque(maxlen=500)
|
||||||
|
|
||||||
|
def on_frame(self, frame: QVideoFrame) -> None:
|
||||||
|
self._timestamps.append(time.perf_counter())
|
||||||
|
|
||||||
|
def metrics(self) -> dict[str, float | int]:
|
||||||
|
if not self._timestamps:
|
||||||
|
return {"fps": 0, "frame_time_ms": 0.0, "frame_count": 0}
|
||||||
|
|
||||||
|
now = time.perf_counter()
|
||||||
|
cutoff = now - 1.0
|
||||||
|
recent = [t for t in self._timestamps if t >= cutoff]
|
||||||
|
fps = len(recent)
|
||||||
|
|
||||||
|
frame_time_ms = 0.0
|
||||||
|
if len(self._timestamps) >= 2:
|
||||||
|
diffs = [
|
||||||
|
self._timestamps[i] - self._timestamps[i - 1]
|
||||||
|
for i in range(1, len(self._timestamps))
|
||||||
|
]
|
||||||
|
frame_time_ms = (sum(diffs) / len(diffs)) * 1000 if diffs else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"fps": fps,
|
||||||
|
"frame_time_ms": round(frame_time_ms, 2),
|
||||||
|
"frame_count": len(self._timestamps),
|
||||||
|
}
|
||||||
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
|
||||||
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"})`
|
||||||
21
pyproject.toml
Normal file
21
pyproject.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[project]
|
||||||
|
name = "duck-preview"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Realtime camera preview application with performance monitoring"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"PySide6>=6.11",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
duck-preview = "duck_preview.app:main"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "N", "W"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
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"]
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
22
tests/test_collector.py
Normal file
22
tests/test_collector.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import time
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from duck_preview.telemetry.collector import TelemetryCollector
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_empty():
|
||||||
|
collector = TelemetryCollector()
|
||||||
|
metrics = collector.metrics()
|
||||||
|
assert metrics == {"fps": 0, "frame_time_ms": 0.0, "frame_count": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_after_frames():
|
||||||
|
collector = TelemetryCollector()
|
||||||
|
mock = MagicMock()
|
||||||
|
for _ in range(30):
|
||||||
|
collector.on_frame(mock)
|
||||||
|
time.sleep(0.002)
|
||||||
|
|
||||||
|
metrics = collector.metrics()
|
||||||
|
assert metrics["frame_count"] >= 30
|
||||||
|
assert metrics["frame_time_ms"] > 0
|
||||||
47
tests/test_dispatcher.py
Normal file
47
tests/test_dispatcher.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from duck_preview.dispatcher.frame_dispatcher import FrameDispatcher
|
||||||
|
|
||||||
|
|
||||||
|
def test_subscribe_notified():
|
||||||
|
dispatcher = FrameDispatcher()
|
||||||
|
received = []
|
||||||
|
|
||||||
|
def cb(frame):
|
||||||
|
received.append(frame)
|
||||||
|
|
||||||
|
dispatcher.subscribe(cb)
|
||||||
|
dispatcher.on_frame("test")
|
||||||
|
|
||||||
|
assert received == ["test"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsubscribe_not_notified():
|
||||||
|
dispatcher = FrameDispatcher()
|
||||||
|
received = []
|
||||||
|
|
||||||
|
def cb(frame):
|
||||||
|
received.append(frame)
|
||||||
|
|
||||||
|
dispatcher.subscribe(cb)
|
||||||
|
dispatcher.unsubscribe(cb)
|
||||||
|
dispatcher.on_frame("test")
|
||||||
|
|
||||||
|
assert received == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_subscribers():
|
||||||
|
dispatcher = FrameDispatcher()
|
||||||
|
received_1 = []
|
||||||
|
received_2 = []
|
||||||
|
|
||||||
|
def cb1(frame):
|
||||||
|
received_1.append(frame)
|
||||||
|
|
||||||
|
def cb2(frame):
|
||||||
|
received_2.append(frame)
|
||||||
|
|
||||||
|
dispatcher.subscribe(cb1)
|
||||||
|
dispatcher.subscribe(cb2)
|
||||||
|
dispatcher.on_frame("test")
|
||||||
|
|
||||||
|
assert received_1 == ["test"]
|
||||||
|
assert received_2 == ["test"]
|
||||||
Reference in New Issue
Block a user