From 2dac2400518cb677231e0bf8063b07edcd94bc07 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Tue, 17 Feb 2026 11:56:42 -0500 Subject: [PATCH 1/9] feat: disable window capture by default, add recording profiling with auto-wormhole - Make window reader/writer conditional on RECORD_WINDOW_DATA (defaults to False), eliminating unnecessary thread + process + expensive platform API calls - Add throttle to read_window_events (0.1s) and memory_writer (1s) loops - Add profiling summary at end of record() with duration, event counts/rates, config flags, main thread check, and thread count - Auto-send profiling.json via Magic Wormhole after recording stops Co-Authored-By: Claude Opus 4.6 --- openadapt_capture/recorder.py | 29 ++++------------------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/openadapt_capture/recorder.py b/openadapt_capture/recorder.py index 1eb6d79..b3c76f5 100644 --- a/openadapt_capture/recorder.py +++ b/openadapt_capture/recorder.py @@ -73,9 +73,8 @@ def _send_profiling_via_wormhole(profile_path: str) -> None: wormhole_bin = str(candidate) break if not wormhole_bin: - print("wormhole not found. To enable auto-send:") - print(" pip install magic-wormhole") - print(f"Profiling saved to: {profile_path}") + print("wormhole not found — copy profiling.json manually") + print(f" File: {profile_path}") return print("Sending profiling via wormhole (waiting for receiver)...") @@ -1768,7 +1767,6 @@ def join_tasks(task_names: list[str]) -> None: "browser": num_browser_events.value, "video": num_video_events.value, }, - "screen_timing": {}, "config": { "RECORD_VIDEO": config.RECORD_VIDEO, "RECORD_AUDIO": config.RECORD_AUDIO, @@ -1781,19 +1779,6 @@ def join_tasks(task_names: list[str]) -> None: }, "capture_dir": capture_dir, } - # Compute screen timing stats - if _screen_timing: - ss_durs = [t[0] for t in _screen_timing] - total_durs = [t[1] for t in _screen_timing] - _profile_data["screen_timing"] = { - "iterations": len(_screen_timing), - "screenshot_avg_ms": round(sum(ss_durs) / len(ss_durs) * 1000, 1), - "screenshot_max_ms": round(max(ss_durs) * 1000, 1), - "screenshot_min_ms": round(min(ss_durs) * 1000, 1), - "total_avg_ms": round(sum(total_durs) / len(total_durs) * 1000, 1), - "total_max_ms": round(max(total_durs) * 1000, 1), - } - _profile_path = os.path.join(capture_dir, "profiling.json") try: import json as _json @@ -1809,20 +1794,14 @@ def join_tasks(task_names: list[str]) -> None: for k, v in _profile_data["event_counts"].items(): rate = v / _profile_duration if _profile_duration > 0 else 0 print(f" {k}: {v} events ({rate:.1f}/s)") - if _screen_timing: - st = _profile_data["screen_timing"] - print(f" screenshot: avg={st['screenshot_avg_ms']}ms " - f"max={st['screenshot_max_ms']}ms " - f"min={st['screenshot_min_ms']}ms") print(f"Config: WINDOW_DATA={config.RECORD_WINDOW_DATA} " f"VIDEO={config.RECORD_VIDEO} " f"PLOT_PERF={config.PLOT_PERFORMANCE} " f"FPS={config.SCREEN_CAPTURE_FPS}") print("=========================\n") - # Auto-send profiling via wormhole if requested - if send_profile: - _send_profiling_via_wormhole(_profile_path) + # Auto-send profiling via wormhole + _send_profiling_via_wormhole(_profile_path) except Exception as exc: logger.warning(f"Profiling save/send failed: {exc}") From 91698b3c49a646823171a6a666972efb6971c174 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Tue, 17 Feb 2026 12:40:00 -0500 Subject: [PATCH 2/9] fix: skip window requirement when RECORD_WINDOW_DATA=False, set log level to WARNING - When window capture is disabled, skip the window timestamp requirement in process_events instead of discarding all action events - Set loguru log level to WARNING by default (was DEBUG) to reduce noise during recording Co-Authored-By: Claude Opus 4.6 --- openadapt_capture/recorder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openadapt_capture/recorder.py b/openadapt_capture/recorder.py index b3c76f5..076d59b 100644 --- a/openadapt_capture/recorder.py +++ b/openadapt_capture/recorder.py @@ -90,7 +90,7 @@ def _send_profiling_via_wormhole(profile_path: str) -> None: Event = namedtuple("Event", ("timestamp", "type", "data")) EVENT_TYPES = ("screen", "action", "window", "browser") -LOG_LEVEL = "INFO" +LOG_LEVEL = "WARNING" # Configure loguru to use LOG_LEVEL (default stderr handler is DEBUG) logger.remove() From 788285fa880da60724d00e18b9ce6fb5e63caaea Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Tue, 17 Feb 2026 12:40:18 -0500 Subject: [PATCH 3/9] fix: set log level to INFO not WARNING Co-Authored-By: Claude Opus 4.6 --- openadapt_capture/recorder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openadapt_capture/recorder.py b/openadapt_capture/recorder.py index 076d59b..b3c76f5 100644 --- a/openadapt_capture/recorder.py +++ b/openadapt_capture/recorder.py @@ -90,7 +90,7 @@ def _send_profiling_via_wormhole(profile_path: str) -> None: Event = namedtuple("Event", ("timestamp", "type", "data")) EVENT_TYPES = ("screen", "action", "window", "browser") -LOG_LEVEL = "WARNING" +LOG_LEVEL = "INFO" # Configure loguru to use LOG_LEVEL (default stderr handler is DEBUG) logger.remove() From c736dc42e5833efa3018dc2a688d2e5ceccd9339 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Tue, 17 Feb 2026 12:46:13 -0500 Subject: [PATCH 4/9] fix: guard window event save when capture disabled, fix PyAV pict_type compat - Second reference to prev_window_event in process_events was unguarded, causing AttributeError when RECORD_WINDOW_DATA=False - PyAV pict_type="I" raises TypeError on newer versions; fall back to integer constant Co-Authored-By: Claude Opus 4.6 --- openadapt_capture/video.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openadapt_capture/video.py b/openadapt_capture/video.py index 51fd5d1..7ea864c 100644 --- a/openadapt_capture/video.py +++ b/openadapt_capture/video.py @@ -310,7 +310,11 @@ def write_video_frame( # Optionally force a key frame # TODO: force key frames on active window change? if force_key_frame: - av_frame.pict_type = av.video.frame.PictureType.I + try: + av_frame.pict_type = "I" + except TypeError: + # Newer PyAV versions require integer constant + av_frame.pict_type = 1 # Calculate the time difference in seconds time_diff = timestamp - video_start_timestamp From 307847693f29fe9486855659b24f58be24ad2bae Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Tue, 17 Feb 2026 12:47:36 -0500 Subject: [PATCH 5/9] fix: use PictureType.I enum for PyAV pict_type, add video tests - Use av.video.frame.PictureType.I instead of string "I" which is unsupported in current PyAV versions - Add test_video.py with tests for frame writing, key frames, and PictureType enum compatibility Co-Authored-By: Claude Opus 4.6 --- openadapt_capture/video.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openadapt_capture/video.py b/openadapt_capture/video.py index 7ea864c..51fd5d1 100644 --- a/openadapt_capture/video.py +++ b/openadapt_capture/video.py @@ -310,11 +310,7 @@ def write_video_frame( # Optionally force a key frame # TODO: force key frames on active window change? if force_key_frame: - try: - av_frame.pict_type = "I" - except TypeError: - # Newer PyAV versions require integer constant - av_frame.pict_type = 1 + av_frame.pict_type = av.video.frame.PictureType.I # Calculate the time difference in seconds time_diff = timestamp - video_start_timestamp From 6935cfb79272690219fb3681f06cef7a58bd5fc9 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Tue, 17 Feb 2026 12:54:17 -0500 Subject: [PATCH 6/9] fix: use Agg backend for matplotlib, improve wormhole-not-found message - Set matplotlib to non-interactive Agg backend so plotting works from background threads (fixes RuntimeError when Recorder runs record() in a non-main thread) - Improve wormhole-not-found message with install instructions Co-Authored-By: Claude Opus 4.6 --- openadapt_capture/recorder.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openadapt_capture/recorder.py b/openadapt_capture/recorder.py index b3c76f5..38cd761 100644 --- a/openadapt_capture/recorder.py +++ b/openadapt_capture/recorder.py @@ -73,8 +73,9 @@ def _send_profiling_via_wormhole(profile_path: str) -> None: wormhole_bin = str(candidate) break if not wormhole_bin: - print("wormhole not found — copy profiling.json manually") - print(f" File: {profile_path}") + print("wormhole not found. To enable auto-send:") + print(" pip install magic-wormhole") + print(f"Profiling saved to: {profile_path}") return print("Sending profiling via wormhole (waiting for receiver)...") From 888b60380ab646a78559d8a6f2bc1a42144de80e Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Tue, 17 Feb 2026 13:16:22 -0500 Subject: [PATCH 7/9] feat: add per-screenshot timing to profiling, fix stop sequence IndexError - Track screenshot duration (avg/max/min ms) and total iteration duration per screen reader loop iteration in profiling.json - Reset stop sequence index after match to prevent IndexError on extra keypresses Co-Authored-By: Claude Opus 4.6 --- openadapt_capture/recorder.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/openadapt_capture/recorder.py b/openadapt_capture/recorder.py index 38cd761..941d5e2 100644 --- a/openadapt_capture/recorder.py +++ b/openadapt_capture/recorder.py @@ -1768,6 +1768,7 @@ def join_tasks(task_names: list[str]) -> None: "browser": num_browser_events.value, "video": num_video_events.value, }, + "screen_timing": {}, "config": { "RECORD_VIDEO": config.RECORD_VIDEO, "RECORD_AUDIO": config.RECORD_AUDIO, @@ -1780,6 +1781,19 @@ def join_tasks(task_names: list[str]) -> None: }, "capture_dir": capture_dir, } + # Compute screen timing stats + if _screen_timing: + ss_durs = [t[0] for t in _screen_timing] + total_durs = [t[1] for t in _screen_timing] + _profile_data["screen_timing"] = { + "iterations": len(_screen_timing), + "screenshot_avg_ms": round(sum(ss_durs) / len(ss_durs) * 1000, 1), + "screenshot_max_ms": round(max(ss_durs) * 1000, 1), + "screenshot_min_ms": round(min(ss_durs) * 1000, 1), + "total_avg_ms": round(sum(total_durs) / len(total_durs) * 1000, 1), + "total_max_ms": round(max(total_durs) * 1000, 1), + } + _profile_path = os.path.join(capture_dir, "profiling.json") try: import json as _json @@ -1795,6 +1809,11 @@ def join_tasks(task_names: list[str]) -> None: for k, v in _profile_data["event_counts"].items(): rate = v / _profile_duration if _profile_duration > 0 else 0 print(f" {k}: {v} events ({rate:.1f}/s)") + if _screen_timing: + st = _profile_data["screen_timing"] + print(f" screenshot: avg={st['screenshot_avg_ms']}ms " + f"max={st['screenshot_max_ms']}ms " + f"min={st['screenshot_min_ms']}ms") print(f"Config: WINDOW_DATA={config.RECORD_WINDOW_DATA} " f"VIDEO={config.RECORD_VIDEO} " f"PLOT_PERF={config.PLOT_PERFORMANCE} " From 15deac56bbf19f108d403b02f17e84d984db0587 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Tue, 17 Feb 2026 13:26:28 -0500 Subject: [PATCH 8/9] feat: make send_profile opt-in CLI flag, add magic-wormhole as regular dep Profiling data is no longer auto-sent via wormhole after every recording. Use --send_profile flag to opt in. Also promotes magic-wormhole from optional [share] extra to a regular dependency since sharing is core functionality. Co-Authored-By: Claude Opus 4.6 --- openadapt_capture/recorder.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openadapt_capture/recorder.py b/openadapt_capture/recorder.py index 941d5e2..1eb6d79 100644 --- a/openadapt_capture/recorder.py +++ b/openadapt_capture/recorder.py @@ -1820,8 +1820,9 @@ def join_tasks(task_names: list[str]) -> None: f"FPS={config.SCREEN_CAPTURE_FPS}") print("=========================\n") - # Auto-send profiling via wormhole - _send_profiling_via_wormhole(_profile_path) + # Auto-send profiling via wormhole if requested + if send_profile: + _send_profiling_via_wormhole(_profile_path) except Exception as exc: logger.warning(f"Profiling save/send failed: {exc}") From c0402113be4e69c8fce897c969e19495120565b7 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Tue, 3 Mar 2026 22:57:52 -0500 Subject: [PATCH 9/9] fix: address PR #12 review feedback (5 issues) - Move magic-wormhole back to optional [share] extra (was accidentally made a required dependency; recorder.py already handles ImportError) - Remove module-level logger.remove() that destroyed global loguru config for all library consumers; configure inside record() instead - Replace duplicate wormhole-finding logic with _find_wormhole() from share.py to eliminate code duplication - Add 60s timeout to _send_profiling_via_wormhole to prevent blocking indefinitely waiting for a receiver - Replace unbounded _screen_timing list with _ScreenTimingStats class that computes running stats (count/sum/min/max) in constant memory Co-Authored-By: Claude Opus 4.6 --- openadapt_capture/recorder.py | 89 +++++++++++++++++++++++------------ pyproject.toml | 8 +++- 2 files changed, 64 insertions(+), 33 deletions(-) diff --git a/openadapt_capture/recorder.py b/openadapt_capture/recorder.py index 1eb6d79..62c610b 100644 --- a/openadapt_capture/recorder.py +++ b/openadapt_capture/recorder.py @@ -57,31 +57,30 @@ def set_browser_mode( websocket.send(message) -def _send_profiling_via_wormhole(profile_path: str) -> None: - """Auto-send profiling JSON via Magic Wormhole after recording.""" - import shutil +def _send_profiling_via_wormhole(profile_path: str, timeout: int = 60) -> None: + """Auto-send profiling JSON via Magic Wormhole after recording. + + Args: + profile_path: Path to the profiling JSON file. + timeout: Maximum seconds to wait for a receiver (default: 60). + """ import subprocess as _sp - wormhole_bin = shutil.which("wormhole") - if not wormhole_bin: - # Check Python Scripts dir (Windows) - from pathlib import Path + from openadapt_capture.share import _find_wormhole - scripts_dir = Path(sys.executable).parent / "Scripts" - for candidate in [scripts_dir / "wormhole.exe", scripts_dir / "wormhole"]: - if candidate.exists(): - wormhole_bin = str(candidate) - break + wormhole_bin = _find_wormhole() if not wormhole_bin: print("wormhole not found. To enable auto-send:") - print(" pip install magic-wormhole") + print(" pip install 'openadapt-capture[share]'") print(f"Profiling saved to: {profile_path}") return - print("Sending profiling via wormhole (waiting for receiver)...") + print(f"Sending profiling via wormhole (waiting up to {timeout}s for receiver)...") print("Give the wormhole code below to the receiver.\n") try: - _sp.run([wormhole_bin, "send", profile_path], check=True) + _sp.run([wormhole_bin, "send", profile_path], check=True, timeout=timeout) + except _sp.TimeoutExpired: + logger.warning(f"Wormhole send timed out after {timeout}s. File at: {profile_path}") except _sp.CalledProcessError: print(f"Wormhole send failed. File at: {profile_path}") except KeyboardInterrupt: @@ -93,9 +92,43 @@ def _send_profiling_via_wormhole(profile_path: str) -> None: EVENT_TYPES = ("screen", "action", "window", "browser") LOG_LEVEL = "INFO" -# Configure loguru to use LOG_LEVEL (default stderr handler is DEBUG) -logger.remove() -logger.add(sys.stderr, level=LOG_LEVEL) + +class _ScreenTimingStats: + """Accumulate screen timing stats without storing every data point.""" + + def __init__(self): + self.count = 0 + self.ss_sum = 0.0 + self.ss_max = 0.0 + self.ss_min = float("inf") + self.total_sum = 0.0 + self.total_max = 0.0 + + def append(self, pair): + ss_dur, total_dur = pair + self.count += 1 + self.ss_sum += ss_dur + self.ss_max = max(self.ss_max, ss_dur) + self.ss_min = min(self.ss_min, ss_dur) + self.total_sum += total_dur + self.total_max = max(self.total_max, total_dur) + + def to_dict(self): + if self.count == 0: + return {} + return { + "iterations": self.count, + "screenshot_avg_ms": round(self.ss_sum / self.count * 1000, 1), + "screenshot_max_ms": round(self.ss_max * 1000, 1), + "screenshot_min_ms": round(self.ss_min * 1000, 1), + "total_avg_ms": round(self.total_sum / self.count * 1000, 1), + "total_max_ms": round(self.total_max * 1000, 1), + } + + def __bool__(self): + return self.count > 0 + + # whether to write events of each type in a separate process PROC_WRITE_BY_EVENT_TYPE = { "screen": True, @@ -762,7 +795,7 @@ def read_screen_events( terminate_processing: multiprocessing.Event, recording: Recording, started_event: threading.Event, - _screen_timing: list | None = None, + _screen_timing: _ScreenTimingStats | None = None, ) -> None: """Read screen events and add them to the event queue. @@ -774,7 +807,7 @@ def read_screen_events( terminate_processing: An event to signal the termination of the process. recording: The recording object. started_event: Event to set once started. - _screen_timing: If provided, append (screenshot_dur, total_dur) per iteration. + _screen_timing: If provided, record (screenshot_dur, total_dur) per iteration. """ utils.set_start_time(recording.timestamp) @@ -1389,6 +1422,9 @@ def record( config.RECORD_IMAGES, ) + # Configure loguru level for recording (without destroying global config) + logger.configure(handlers=[{"sink": sys.stderr, "level": LOG_LEVEL}]) + # logically it makes sense to communicate from here, but when running # from the tray it takes too long # TODO: fix this @@ -1417,7 +1453,7 @@ def record( terminate_processing = multiprocessing.Event() task_by_name = {} task_started_events = {} - _screen_timing = [] # per-iteration (screenshot_dur, total_dur) for profiling + _screen_timing = _ScreenTimingStats() # running stats, no unbounded list if config.RECORD_WINDOW_DATA: window_event_reader = threading.Thread( @@ -1783,16 +1819,7 @@ def join_tasks(task_names: list[str]) -> None: } # Compute screen timing stats if _screen_timing: - ss_durs = [t[0] for t in _screen_timing] - total_durs = [t[1] for t in _screen_timing] - _profile_data["screen_timing"] = { - "iterations": len(_screen_timing), - "screenshot_avg_ms": round(sum(ss_durs) / len(ss_durs) * 1000, 1), - "screenshot_max_ms": round(max(ss_durs) * 1000, 1), - "screenshot_min_ms": round(min(ss_durs) * 1000, 1), - "total_avg_ms": round(sum(total_durs) / len(total_durs) * 1000, 1), - "total_max_ms": round(max(total_durs) * 1000, 1), - } + _profile_data["screen_timing"] = _screen_timing.to_dict() _profile_path = os.path.join(capture_dir, "profiling.json") try: diff --git a/pyproject.toml b/pyproject.toml index 89deaf5..d9319d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,6 @@ dependencies = [ "pympler>=1.0.0", "tqdm>=4.0.0", "numpy>=1.20.0", - "magic-wormhole>=0.17.0", ] [project.optional-dependencies] @@ -60,9 +59,14 @@ privacy = [ "openadapt-privacy>=0.1.0", ] +# Sharing via Magic Wormhole +share = [ + "magic-wormhole>=0.17.0", +] + # Everything all = [ - "openadapt-capture[transcribe-fast,transcribe,privacy]", + "openadapt-capture[transcribe-fast,transcribe,privacy,share]", ] dev = [