Skip to content

Commit 6b36c3c

Browse files
committed
Add recording lifecycle hooks for processors
Wire the main window to notify custom DLC processors when recording starts/stops, with a shared recording context (run dir, filename stem, and file metadata). Refactor processor lookup into a helper and only fall back to generic save() if no stop hook handles persistence. Extend BaseProcessorSocket with recording context/save-path state, start/stop hook methods, and a stop(save=...) option. Update save() to use an explicit path or configured default path, create parent directories, and improve logging for skipped/failed saves.
1 parent 0ee0274 commit 6b36c3c

2 files changed

Lines changed: 162 additions & 14 deletions

File tree

‎dlclivegui/gui/main_window.py‎

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1793,6 +1793,7 @@ def _start_multi_camera_recording(self) -> None:
17931793
if run_dir is None:
17941794
self._show_error("Failed to start recording.")
17951795
return
1796+
self._notify_processor_recording_started(run_dir)
17961797
self.multi_camera_controller.set_recording_sink(self._rec_manager.write_frame)
17971798
self.multi_camera_controller.set_recording_frame_do_emit(True)
17981799

@@ -1834,6 +1835,20 @@ def worker():
18341835
daemon=True,
18351836
).start()
18361837

1838+
def _get_dlc_processor_instance(self):
1839+
"""Return the active custom DLC processor instance, if available."""
1840+
processor = getattr(self._dlc, "_processor", None)
1841+
1842+
if processor is not None:
1843+
return processor
1844+
1845+
# Fallback: if DLCLive owns it internally.
1846+
dlc_obj = getattr(self._dlc, "_dlc", None)
1847+
if dlc_obj is not None:
1848+
return getattr(dlc_obj, "processor", None)
1849+
1850+
return None
1851+
18371852
def _save_processor_data_if_available(self) -> None:
18381853
"""Best-effort generic processor save.
18391854
@@ -1845,12 +1860,7 @@ def _save_processor_data_if_available(self) -> None:
18451860
18461861
Return values are only logged; failure should not crash the GUI.
18471862
"""
1848-
processor = getattr(self._dlc, "_processor", None)
1849-
1850-
# Fallback in case DLCLive owns the processor but _processor was not updated.
1851-
if processor is None:
1852-
dlc_obj = getattr(self._dlc, "_dlc", None)
1853-
processor = getattr(dlc_obj, "processor", None) if dlc_obj is not None else None
1863+
processor = self._get_dlc_processor_instance()
18541864

18551865
if processor is None:
18561866
logger.debug("Processor save skipped: no processor instance available.")
@@ -1867,8 +1877,82 @@ def _save_processor_data_if_available(self) -> None:
18671877
except Exception:
18681878
logger.exception("Processor save() failed.")
18691879

1880+
def _notify_processor_recording_started(self, run_dir) -> None:
1881+
processor = self._get_dlc_processor_instance()
1882+
if processor is None:
1883+
return
1884+
1885+
hook = getattr(processor, "on_recording_started", None)
1886+
if not callable(hook):
1887+
return
1888+
1889+
try:
1890+
context = self._build_processor_recording_context(run_dir)
1891+
hook(context)
1892+
logger.info("Notified processor recording started: %s", context)
1893+
except Exception:
1894+
logger.exception("Processor on_recording_started hook failed")
1895+
1896+
from pathlib import Path
1897+
1898+
def _build_processor_recording_context(self, run_dir) -> dict:
1899+
run_dir = Path(run_dir) if run_dir is not None else None
1900+
1901+
file_context = {}
1902+
try:
1903+
file_context = self._rec_manager.get_recording_file_context()
1904+
except Exception:
1905+
logger.exception("Failed to get recording file context from RecordingManager")
1906+
file_context = {}
1907+
1908+
if run_dir is None:
1909+
run_dir = file_context.get("run_dir", None)
1910+
if run_dir is not None:
1911+
run_dir = Path(run_dir)
1912+
1913+
session_name = ""
1914+
if hasattr(self, "session_name_edit"):
1915+
session_name = self.session_name_edit.text().strip()
1916+
1917+
filename = ""
1918+
if hasattr(self, "filename_edit"):
1919+
filename = self.filename_edit.text().strip()
1920+
1921+
filename_stem = Path(filename or session_name or "recording").stem
1922+
1923+
ctx = {
1924+
"run_dir": run_dir,
1925+
"session_name": session_name,
1926+
"filename": filename,
1927+
"filename_stem": filename_stem,
1928+
"processor_base_path": run_dir / filename_stem if run_dir is not None else None,
1929+
}
1930+
ctx.update(file_context)
1931+
return ctx
1932+
1933+
def _notify_processor_recording_stopped(self) -> None:
1934+
processor = self._get_dlc_processor_instance()
1935+
if processor is None:
1936+
return False
1937+
1938+
hook = getattr(processor, "on_recording_stopped", None)
1939+
if not callable(hook):
1940+
return False
1941+
1942+
try:
1943+
run_dir = getattr(self._rec_manager, "run_dir", None)
1944+
context = self._build_processor_recording_context(run_dir)
1945+
hook(context)
1946+
logger.info("Notified processor recording stopped")
1947+
return True
1948+
except Exception:
1949+
logger.exception("Processor on_recording_stopped hook failed")
1950+
return False
1951+
18701952
def _on_recording_stopped_async(self) -> None:
1871-
self._save_processor_data_if_available()
1953+
handled_by_stop_hook = self._notify_processor_recording_stopped()
1954+
if not handled_by_stop_hook:
1955+
self._save_processor_data_if_available()
18721956

18731957
self._recording_stopping = False
18741958
self.start_record_button.setEnabled(True)

‎dlclivegui/processors/dlc_processor_socket.py‎

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ def __init__(
8686
self._vid_recording = Event()
8787
self.curr_step = 0
8888
self.save_original = save_original
89+
self.recording_context = {}
90+
self.save_path = None
8991

9092
# Networking (optional)
9193
self.address = bind
@@ -265,8 +267,14 @@ def stop_recording(self):
265267
# STOP / SHUTDOWN
266268
# --------------------------------------------------------------------------------------
267269

268-
def stop(self):
270+
def stop(self, save: bool = False, file=None):
269271
"""Gracefully stop listener and clients."""
272+
if save:
273+
try:
274+
self.save(file)
275+
except Exception:
276+
logger.exception("Failed to save processor data on stop")
277+
270278
if self._stop.is_set():
271279
return
272280

@@ -370,23 +378,79 @@ def _clear_data_queues(self):
370378
self.original_pose.clear()
371379

372380
def save(self, file=None):
373-
if not file:
381+
target = file
382+
383+
if target is None:
384+
target = getattr(self, "save_path", None)
385+
386+
if target is None:
387+
logger.warning("Processor save skipped: no file or save_path provided.")
374388
return 0
389+
375390
try:
376391
save_dict = self.get_data()
377-
path2save = Path(__file__).parent.parent.parent / "data" / file
378-
path2save.parent.mkdir(parents=True, exist_ok=True)
392+
save_path = Path(target)
393+
save_path.parent.mkdir(parents=True, exist_ok=True)
394+
379395
if self.save_original:
380396
original_pose = save_dict.pop("original_pose")
381-
self.save_original_pose(original_pose, save_dict["frame_time"], save_dict["time_stamp"], path2save)
382-
with open(path2save, "wb") as f:
397+
self.save_original_pose(
398+
original_pose,
399+
save_dict["frame_time"],
400+
save_dict["time_stamp"],
401+
save_path,
402+
)
403+
404+
with open(save_path, "wb") as f:
383405
pickle.dump(save_dict, f)
384-
logger.info(f"Saved data to {path2save}")
406+
407+
logger.info(f"Saved processor data to {save_path}")
385408
return 1
409+
386410
except Exception as e:
387411
logger.error(f"Save failed: {e}")
388412
return -1
389413

414+
def set_recording_context(self, context: dict | None) -> None:
415+
"""Set GUI-provided recording context.
416+
417+
This is intentionally generic. Custom processors may use it to derive
418+
processor-specific output paths.
419+
420+
Expected keys may include:
421+
run_dir
422+
session_name
423+
filename
424+
filename_stem
425+
processor_base_path
426+
video_files
427+
timestamp_files
428+
"""
429+
self.recording_context = dict(context or {})
430+
431+
base_path = self.recording_context.get("processor_base_path")
432+
if base_path is not None:
433+
self.save_path = Path(base_path)
434+
435+
def set_save_path(self, path) -> None:
436+
"""Set default save path used by save() when no file is provided."""
437+
self.save_path = Path(path) if path is not None else None
438+
439+
def get_save_path(self):
440+
"""Return default save path, if any."""
441+
return getattr(self, "save_path", None)
442+
443+
def on_recording_started(self, context: dict) -> None:
444+
"""Optional hook called by GUI when recording starts."""
445+
self.set_recording_context(context)
446+
447+
def on_recording_stopped(self, context: dict) -> None:
448+
"""Optional hook called by GUI when recording stops.
449+
450+
Base implementation only updates context. Custom processors can override.
451+
"""
452+
self.set_recording_context(context)
453+
390454
def save_original_pose(
391455
self,
392456
original_pose: np.ndarray,

0 commit comments

Comments
 (0)