Skip to content

Commit 46e229c

Browse files
committed
Defer recording until preview frames are ready
Adds a pending-recording flow in the main window so "Start recording" while preview is stopped first starts preview, then begins recording only after all active cameras have produced frames. The pending state is cleared on stop/error/init failure to avoid stale triggers and duplicate starts. Adds GUI tests covering deferred start, waiting for all camera frames, frame-ready trigger behavior, and no double-starts.
1 parent c8e7d2f commit 46e229c

2 files changed

Lines changed: 206 additions & 0 deletions

File tree

‎dlclivegui/gui/main_window.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ def __init__(self, config: ApplicationSettings | None = None):
137137
self._raw_frame: np.ndarray | None = None
138138
self._last_pose: PoseResult | None = None
139139
self._dlc_active: bool = False
140+
self._pending_recording_after_preview = False
140141
self._active_camera_settings: CameraSettings | None = None
141142
self._last_drop_warning = 0.0
142143
self._last_recorder_summary = "Recorder idle"
@@ -1422,6 +1423,7 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None:
14221423
"""
14231424
self._multi_camera_frames = frame_data.frames
14241425
self._multi_camera_display_ids = frame_data.display_ids or {}
1426+
self._try_start_pending_recording()
14251427
src_id = frame_data.source_camera_id
14261428
if src_id:
14271429
self._fps_tracker.note_frame(src_id) # Track FPS
@@ -1487,6 +1489,7 @@ def _on_multi_camera_stopped(self) -> None:
14871489
"""Handle all cameras stopped event."""
14881490
# Stop all multi-camera recorders
14891491
self._stop_multi_camera_recording()
1492+
self._pending_recording_after_preview = False
14901493

14911494
self.preview_button.setEnabled(True)
14921495
self.stop_preview_button.setEnabled(False)
@@ -1501,6 +1504,7 @@ def _on_multi_camera_stopped(self) -> None:
15011504

15021505
def _on_multi_camera_error(self, camera_id: str, message: str) -> None:
15031506
"""Handle error from a camera in multi-camera mode."""
1507+
self._pending_recording_after_preview = False
15041508
self._show_warning(f"Camera {camera_id} error: {message}\nRecording stopped.")
15051509
self._refresh_dlc_camera_list_running()
15061510
if self.dlc_camera_combo.count() <= 1:
@@ -1509,6 +1513,7 @@ def _on_multi_camera_error(self, camera_id: str, message: str) -> None:
15091513

15101514
def _on_multi_camera_initialization_failed(self, failures: list) -> None:
15111515
"""Handle complete failure to initialize cameras."""
1516+
self._pending_recording_after_preview = False
15121517
# Build error message with details for each failed camera
15131518
error_lines = ["Failed to initialize camera(s):"]
15141519
for camera_id, error_msg in failures:
@@ -1669,6 +1674,7 @@ def _stop_preview(self) -> None:
16691674
self._stop_multi_camera_recording()
16701675

16711676
self.multi_camera_controller.stop()
1677+
self._pending_recording_after_preview = False
16721678
self._stop_inference(show_message=False)
16731679
self._fps_tracker.clear()
16741680
self._last_display_time = 0.0
@@ -1952,6 +1958,7 @@ def _start_recording(self) -> None:
19521958
"""Start recording from all active cameras."""
19531959
# Auto-start preview if not running
19541960
if not self.multi_camera_controller.is_running():
1961+
self._pending_recording_after_preview = True
19551962
self._start_preview()
19561963
# Wait a moment for cameras to initialize before recording
19571964
# The recording will start after preview is confirmed running
@@ -1963,6 +1970,32 @@ def _start_recording(self) -> None:
19631970
# Preview already running, start recording immediately
19641971
self._start_multi_camera_recording()
19651972

1973+
def _try_start_pending_recording(self) -> None:
1974+
if not self._pending_recording_after_preview:
1975+
return
1976+
1977+
if self._rec_manager.is_active:
1978+
self._pending_recording_after_preview = False
1979+
return
1980+
1981+
if not self.multi_camera_controller.is_running():
1982+
return
1983+
1984+
active_cams = self._config.multi_camera.get_active_cameras()
1985+
expected_ids = {get_camera_id(cam) for cam in active_cams}
1986+
1987+
if not expected_ids:
1988+
self._pending_recording_after_preview = False
1989+
return
1990+
1991+
available_ids = set(self._multi_camera_frames.keys())
1992+
1993+
if not expected_ids.issubset(available_ids):
1994+
return
1995+
1996+
self._pending_recording_after_preview = False
1997+
self._start_multi_camera_recording()
1998+
19661999
def _stop_recording(self) -> None:
19672000
"""Stop recording from all cameras."""
19682001
self._stop_multi_camera_recording()

‎tests/gui/test_recording_gui.py‎

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import numpy as np
2+
import pytest
3+
4+
from dlclivegui.services.multi_camera_controller import MultiFrameData, get_camera_id
5+
6+
7+
@pytest.mark.gui
8+
class TestPendingRecordingAfterPreview:
9+
def test_start_recording_when_preview_stopped_defers_until_preview_frames(
10+
self,
11+
window,
12+
monkeypatch,
13+
):
14+
calls = {
15+
"start_preview": 0,
16+
"start_recording": 0,
17+
}
18+
19+
monkeypatch.setattr(
20+
window.multi_camera_controller,
21+
"is_running",
22+
lambda: False,
23+
)
24+
25+
def fake_start_preview():
26+
calls["start_preview"] += 1
27+
28+
def fake_start_multi_camera_recording():
29+
calls["start_recording"] += 1
30+
31+
monkeypatch.setattr(window, "_start_preview", fake_start_preview)
32+
monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording)
33+
34+
window._pending_recording_after_preview = False
35+
36+
window._start_recording()
37+
38+
assert calls["start_preview"] == 1
39+
assert calls["start_recording"] == 0
40+
assert window._pending_recording_after_preview is True
41+
42+
def test_pending_recording_waits_until_all_active_cameras_have_frames(
43+
self,
44+
window,
45+
monkeypatch,
46+
):
47+
active_cams = window._config.multi_camera.get_active_cameras()
48+
assert len(active_cams) >= 2
49+
50+
cam0_id = get_camera_id(active_cams[0])
51+
cam1_id = get_camera_id(active_cams[1])
52+
53+
calls = {
54+
"start_recording": 0,
55+
}
56+
57+
monkeypatch.setattr(
58+
window.multi_camera_controller,
59+
"is_running",
60+
lambda: True,
61+
)
62+
63+
def fake_start_multi_camera_recording():
64+
calls["start_recording"] += 1
65+
66+
monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording)
67+
68+
window._pending_recording_after_preview = True
69+
window._multi_camera_frames = {
70+
cam0_id: np.zeros((10, 10, 3), dtype=np.uint8),
71+
}
72+
73+
window._try_start_pending_recording()
74+
75+
assert calls["start_recording"] == 0
76+
assert window._pending_recording_after_preview is True
77+
78+
window._multi_camera_frames[cam1_id] = np.zeros((10, 10, 3), dtype=np.uint8)
79+
80+
window._try_start_pending_recording()
81+
82+
assert calls["start_recording"] == 1
83+
assert window._pending_recording_after_preview is False
84+
85+
def test_pending_recording_is_triggered_from_multi_frame_processing_ready(
86+
self,
87+
window,
88+
monkeypatch,
89+
):
90+
active_cams = window._config.multi_camera.get_active_cameras()
91+
assert len(active_cams) >= 2
92+
93+
cam0_id = get_camera_id(active_cams[0])
94+
cam1_id = get_camera_id(active_cams[1])
95+
96+
calls = {
97+
"start_recording": 0,
98+
}
99+
100+
monkeypatch.setattr(
101+
window.multi_camera_controller,
102+
"is_running",
103+
lambda: True,
104+
)
105+
106+
def fake_start_multi_camera_recording():
107+
calls["start_recording"] += 1
108+
109+
monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording)
110+
111+
window._pending_recording_after_preview = True
112+
113+
frame0 = np.zeros((10, 10, 3), dtype=np.uint8)
114+
frame1 = np.zeros((10, 10, 3), dtype=np.uint8)
115+
116+
frame_data = MultiFrameData(
117+
frames={
118+
cam0_id: frame0,
119+
cam1_id: frame1,
120+
},
121+
timestamps={
122+
cam0_id: 1.0,
123+
cam1_id: 1.0,
124+
},
125+
source_camera_id=cam0_id,
126+
display_ids={
127+
cam0_id: "Cam0",
128+
cam1_id: "Cam1",
129+
},
130+
)
131+
132+
window._on_multi_frame_processing_ready(frame_data)
133+
134+
assert calls["start_recording"] == 1
135+
assert window._pending_recording_after_preview is False
136+
137+
def test_pending_recording_does_not_start_twice(
138+
self,
139+
window,
140+
monkeypatch,
141+
):
142+
active_cams = window._config.multi_camera.get_active_cameras()
143+
assert len(active_cams) >= 2
144+
145+
cam0_id = get_camera_id(active_cams[0])
146+
cam1_id = get_camera_id(active_cams[1])
147+
148+
calls = {
149+
"start_recording": 0,
150+
}
151+
152+
monkeypatch.setattr(
153+
window.multi_camera_controller,
154+
"is_running",
155+
lambda: True,
156+
)
157+
158+
def fake_start_multi_camera_recording():
159+
calls["start_recording"] += 1
160+
161+
monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording)
162+
163+
window._pending_recording_after_preview = True
164+
window._multi_camera_frames = {
165+
cam0_id: np.zeros((10, 10, 3), dtype=np.uint8),
166+
cam1_id: np.zeros((10, 10, 3), dtype=np.uint8),
167+
}
168+
169+
window._try_start_pending_recording()
170+
window._try_start_pending_recording()
171+
172+
assert calls["start_recording"] == 1
173+
assert window._pending_recording_after_preview is False

0 commit comments

Comments
 (0)