wintegrate 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wintegrate/__init__.py +114 -0
- wintegrate/apps.py +100 -0
- wintegrate/diagnostics.py +409 -0
- wintegrate/element.py +799 -0
- wintegrate/env.py +105 -0
- wintegrate/exceptions.py +43 -0
- wintegrate/interop.py +877 -0
- wintegrate/recorder.py +234 -0
- wintegrate/session.py +465 -0
- wintegrate/text.py +36 -0
- wintegrate/window.py +340 -0
- wintegrate-0.1.0.dist-info/METADATA +210 -0
- wintegrate-0.1.0.dist-info/RECORD +15 -0
- wintegrate-0.1.0.dist-info/WHEEL +4 -0
- wintegrate-0.1.0.dist-info/licenses/LICENSE +21 -0
wintegrate/__init__.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""wintegrate: A CI-first Windows UI automation library designed for unattended runners."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError
|
|
6
|
+
from importlib.metadata import version as _version
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = _version("wintegrate")
|
|
10
|
+
except PackageNotFoundError: # running from a source tree without an install
|
|
11
|
+
__version__ = "0.0.0.dev0"
|
|
12
|
+
|
|
13
|
+
from wintegrate.apps import CALCULATOR, NOTEPAD, AppHandle, AppSpec
|
|
14
|
+
from wintegrate.diagnostics import CensusDiff, ContinuousRecorder, WindowCensus, WindowSnapshot
|
|
15
|
+
from wintegrate.element import UiaElement
|
|
16
|
+
from wintegrate.env import (
|
|
17
|
+
PlatformCapabilities,
|
|
18
|
+
desktop_only,
|
|
19
|
+
env,
|
|
20
|
+
is_windows_desktop,
|
|
21
|
+
is_windows_server,
|
|
22
|
+
server_only,
|
|
23
|
+
)
|
|
24
|
+
from wintegrate.exceptions import (
|
|
25
|
+
ActionVerificationError,
|
|
26
|
+
DiagnosticPipelineError,
|
|
27
|
+
ElementNotFoundError,
|
|
28
|
+
FocusStealDetectedError,
|
|
29
|
+
TextMismatchError,
|
|
30
|
+
WindowDiscoveryTimeoutError,
|
|
31
|
+
WintegrateError,
|
|
32
|
+
)
|
|
33
|
+
from wintegrate.interop import (
|
|
34
|
+
KEY_NAMES,
|
|
35
|
+
get_composition_string,
|
|
36
|
+
get_foreground_window,
|
|
37
|
+
get_ime_status,
|
|
38
|
+
get_keyboard_layout,
|
|
39
|
+
get_keyboard_layout_list,
|
|
40
|
+
get_process_image_name,
|
|
41
|
+
get_window_class,
|
|
42
|
+
get_window_pid,
|
|
43
|
+
get_window_title,
|
|
44
|
+
parse_key_spec,
|
|
45
|
+
send_char_input,
|
|
46
|
+
send_keys,
|
|
47
|
+
send_mouse_click,
|
|
48
|
+
send_physical_keys,
|
|
49
|
+
send_vk_input,
|
|
50
|
+
set_ime_conversion,
|
|
51
|
+
set_ime_open,
|
|
52
|
+
)
|
|
53
|
+
from wintegrate.recorder import (
|
|
54
|
+
ActionPlayer,
|
|
55
|
+
RecordedAction,
|
|
56
|
+
TextActionTimelineRecorder,
|
|
57
|
+
inspect_desktop_tree,
|
|
58
|
+
)
|
|
59
|
+
from wintegrate.session import Session, SessionConfig
|
|
60
|
+
from wintegrate.text import count_lines, normalize_line_endings
|
|
61
|
+
from wintegrate.window import Window
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"__version__",
|
|
65
|
+
"Session",
|
|
66
|
+
"SessionConfig",
|
|
67
|
+
"Window",
|
|
68
|
+
"UiaElement",
|
|
69
|
+
"AppSpec",
|
|
70
|
+
"AppHandle",
|
|
71
|
+
"NOTEPAD",
|
|
72
|
+
"CALCULATOR",
|
|
73
|
+
"env",
|
|
74
|
+
"PlatformCapabilities",
|
|
75
|
+
"is_windows_server",
|
|
76
|
+
"is_windows_desktop",
|
|
77
|
+
"desktop_only",
|
|
78
|
+
"server_only",
|
|
79
|
+
"normalize_line_endings",
|
|
80
|
+
"count_lines",
|
|
81
|
+
"send_char_input",
|
|
82
|
+
"send_keys",
|
|
83
|
+
"send_vk_input",
|
|
84
|
+
"send_physical_keys",
|
|
85
|
+
"get_ime_status",
|
|
86
|
+
"set_ime_open",
|
|
87
|
+
"set_ime_conversion",
|
|
88
|
+
"get_composition_string",
|
|
89
|
+
"get_keyboard_layout",
|
|
90
|
+
"get_keyboard_layout_list",
|
|
91
|
+
"parse_key_spec",
|
|
92
|
+
"KEY_NAMES",
|
|
93
|
+
"send_mouse_click",
|
|
94
|
+
"get_foreground_window",
|
|
95
|
+
"get_process_image_name",
|
|
96
|
+
"get_window_class",
|
|
97
|
+
"get_window_pid",
|
|
98
|
+
"get_window_title",
|
|
99
|
+
"WintegrateError",
|
|
100
|
+
"WindowDiscoveryTimeoutError",
|
|
101
|
+
"ElementNotFoundError",
|
|
102
|
+
"ActionVerificationError",
|
|
103
|
+
"TextMismatchError",
|
|
104
|
+
"FocusStealDetectedError",
|
|
105
|
+
"DiagnosticPipelineError",
|
|
106
|
+
"ContinuousRecorder",
|
|
107
|
+
"WindowCensus",
|
|
108
|
+
"WindowSnapshot",
|
|
109
|
+
"CensusDiff",
|
|
110
|
+
"RecordedAction",
|
|
111
|
+
"TextActionTimelineRecorder",
|
|
112
|
+
"ActionPlayer",
|
|
113
|
+
"inspect_desktop_tree",
|
|
114
|
+
]
|
wintegrate/apps.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Well-known application specs and a managed application lifecycle handle.
|
|
2
|
+
|
|
3
|
+
These package the launch/discovery pitfalls of modern Windows apps so callers
|
|
4
|
+
don't have to rediscover them:
|
|
5
|
+
|
|
6
|
+
- Titles are localized; process image names, window classes, and UIA control
|
|
7
|
+
types are not. AppSpec matching prefers the locale-independent identities and
|
|
8
|
+
keeps ``title_pattern`` only as a last-resort fallback (this is also where
|
|
9
|
+
localized variants live, once, instead of in every caller).
|
|
10
|
+
- Store apps are single-instance: a leaked instance makes the next launch open
|
|
11
|
+
a tab in the old window instead of a new top-level window, breaking window
|
|
12
|
+
discovery. ``fresh`` sweeps leftover instances before launching.
|
|
13
|
+
- Cold Store-app starts regularly exceed 10s on CI runners (ARM64 especially),
|
|
14
|
+
so managed launches default to a generous discovery timeout.
|
|
15
|
+
- A launch that times out must not leak: AppHandle is a context manager that
|
|
16
|
+
guarantees window close + process kill on exit.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import subprocess
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
from wintegrate.element import UiaElement
|
|
26
|
+
from wintegrate.window import Window
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class AppSpec:
|
|
33
|
+
"""Locale-independent identity of an application for launch-and-discover."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
command: tuple[str, ...]
|
|
37
|
+
process_names: tuple[str, ...] = ()
|
|
38
|
+
window_classes: tuple[str, ...] = ()
|
|
39
|
+
title_pattern: str | None = None
|
|
40
|
+
text_input_ladder: tuple[dict, ...] | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
NOTEPAD = AppSpec(
|
|
44
|
+
name="notepad",
|
|
45
|
+
command=("notepad.exe",),
|
|
46
|
+
process_names=("notepad.exe",),
|
|
47
|
+
window_classes=("Notepad",), # same class for classic and Store Notepad
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
CALCULATOR = AppSpec(
|
|
51
|
+
name="calculator",
|
|
52
|
+
command=("calc.exe",),
|
|
53
|
+
# Win11 WinUI / Win10 UWP / legacy Server builds
|
|
54
|
+
process_names=("calculatorapp.exe", "calculator.exe", "win32calc.exe"),
|
|
55
|
+
# UWP windows can be owned by ApplicationFrameHost rather than the app
|
|
56
|
+
# process, so keep a title fallback (localized variants live here, once).
|
|
57
|
+
title_pattern="(?i)calculator|小算盤|计算器|電卓",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AppHandle:
|
|
62
|
+
"""Owns one launched application: window + launcher process + guaranteed cleanup."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
proc: subprocess.Popen | None,
|
|
67
|
+
window: Window,
|
|
68
|
+
spec: AppSpec | None = None,
|
|
69
|
+
):
|
|
70
|
+
self.proc = proc
|
|
71
|
+
self.window = window
|
|
72
|
+
self.spec = spec
|
|
73
|
+
|
|
74
|
+
def find_text_input(self, timeout: float = 20.0) -> UiaElement:
|
|
75
|
+
ladder = self.spec.text_input_ladder if self.spec else None
|
|
76
|
+
return self.window.find_text_input(timeout=timeout, ladder=ladder)
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
try:
|
|
80
|
+
self.window.close(force=True)
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
logger.debug(f"AppHandle window close failed ({type(exc).__name__}): {exc}")
|
|
83
|
+
if self.proc:
|
|
84
|
+
try:
|
|
85
|
+
self.proc.kill()
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
def __enter__(self) -> AppHandle:
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
|
|
93
|
+
self.close()
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def kill_processes(names: tuple[str, ...] | list[str]) -> None:
|
|
98
|
+
"""Best-effort force-kill by image name (fresh-launch sweep for single-instance apps)."""
|
|
99
|
+
for name in names:
|
|
100
|
+
subprocess.run(["taskkill", "/F", "/IM", name], capture_output=True, check=False)
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""CI-first diagnostic subsystem: streaming video recorder, desktop window census, and process tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import glob
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from fractions import Fraction
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from wintegrate.exceptions import DiagnosticPipelineError
|
|
18
|
+
from wintegrate.interop import (
|
|
19
|
+
BITMAPINFOHEADER,
|
|
20
|
+
WNDENUMPROC,
|
|
21
|
+
attach_to_input_desktop,
|
|
22
|
+
gdi32,
|
|
23
|
+
get_input_desktop_handle,
|
|
24
|
+
get_window_class,
|
|
25
|
+
get_window_pid,
|
|
26
|
+
get_window_title,
|
|
27
|
+
user32,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
_PILLOW_HINT = (
|
|
33
|
+
"Screen capture needs Pillow, which ships in the optional 'video' extra: "
|
|
34
|
+
"pip install 'wintegrate[video]'"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_pil_image():
|
|
39
|
+
"""Imports PIL.Image on demand so the core install does not require Pillow."""
|
|
40
|
+
try:
|
|
41
|
+
from PIL import Image
|
|
42
|
+
except ImportError as exc: # pragma: no cover - depends on install extras
|
|
43
|
+
raise DiagnosticPipelineError(_PILLOW_HINT) from exc
|
|
44
|
+
return Image
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def capture_screen_image():
|
|
48
|
+
"""Captures the current primary display using GDI BitBlt into a PIL Image."""
|
|
49
|
+
Image = _load_pil_image()
|
|
50
|
+
w = user32.GetSystemMetrics(0)
|
|
51
|
+
h = user32.GetSystemMetrics(1)
|
|
52
|
+
hdc_screen = user32.GetDC(0)
|
|
53
|
+
hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)
|
|
54
|
+
hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, w, h)
|
|
55
|
+
gdi32.SelectObject(hdc_mem, hbmp)
|
|
56
|
+
gdi32.BitBlt(hdc_mem, 0, 0, w, h, hdc_screen, 0, 0, 0x00CC0020)
|
|
57
|
+
|
|
58
|
+
bmi = BITMAPINFOHEADER()
|
|
59
|
+
bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER)
|
|
60
|
+
bmi.biWidth = w
|
|
61
|
+
bmi.biHeight = -h # top-down DIB
|
|
62
|
+
bmi.biPlanes = 1
|
|
63
|
+
bmi.biBitCount = 32
|
|
64
|
+
bmi.biCompression = 0
|
|
65
|
+
buf = ctypes.create_string_buffer(w * h * 4)
|
|
66
|
+
gdi32.GetDIBits(hdc_mem, hbmp, 0, h, buf, ctypes.byref(bmi), 0)
|
|
67
|
+
|
|
68
|
+
gdi32.DeleteDC(hdc_mem)
|
|
69
|
+
user32.ReleaseDC(0, hdc_screen)
|
|
70
|
+
gdi32.DeleteObject(hbmp)
|
|
71
|
+
|
|
72
|
+
return Image.frombuffer("RGBA", (w, h), buf, "raw", "BGRA", 0, 1).convert("RGB")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_ffmpeg_exe() -> str | None:
|
|
76
|
+
"""
|
|
77
|
+
Finds a verified, existing ffmpeg executable across x64 and ARM64.
|
|
78
|
+
Searches system PATH, standard Windows / Chocolatey / WinGet directories,
|
|
79
|
+
and imageio_ffmpeg.
|
|
80
|
+
"""
|
|
81
|
+
# 1. System PATH
|
|
82
|
+
exe = shutil.which("ffmpeg")
|
|
83
|
+
if exe and os.path.isfile(exe):
|
|
84
|
+
return exe
|
|
85
|
+
|
|
86
|
+
# 2. Common Windows and Package Manager directories
|
|
87
|
+
common_patterns = [
|
|
88
|
+
r"C:\ProgramData\chocolatey\bin\ffmpeg.exe",
|
|
89
|
+
r"C:\Program Files\FFmpeg\bin\ffmpeg.exe",
|
|
90
|
+
r"C:\ffmpeg\bin\ffmpeg.exe",
|
|
91
|
+
r"C:\tools\ffmpeg\bin\ffmpeg.exe",
|
|
92
|
+
r"C:\Users\*\AppData\Local\Microsoft\WinGet\Packages\*\*\bin\ffmpeg.exe",
|
|
93
|
+
r"C:\Users\*\AppData\Local\Microsoft\WinGet\Links\ffmpeg.exe",
|
|
94
|
+
]
|
|
95
|
+
for pattern in common_patterns:
|
|
96
|
+
matches = glob.glob(pattern)
|
|
97
|
+
for m in matches:
|
|
98
|
+
if os.path.isfile(m):
|
|
99
|
+
return m
|
|
100
|
+
|
|
101
|
+
# 3. imageio_ffmpeg fallback (verify file exists)
|
|
102
|
+
try:
|
|
103
|
+
import imageio_ffmpeg
|
|
104
|
+
|
|
105
|
+
exe = imageio_ffmpeg.get_ffmpeg_exe()
|
|
106
|
+
if exe and os.path.isfile(exe):
|
|
107
|
+
return exe
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
logger.debug(f"imageio_ffmpeg lookup failed ({type(exc).__name__}): {exc}")
|
|
110
|
+
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ContinuousRecorder:
|
|
115
|
+
"""
|
|
116
|
+
Low-memory streaming desktop screen recorder.
|
|
117
|
+
|
|
118
|
+
Encodes in-process through PyAV (which bundles FFmpeg) when it is installed,
|
|
119
|
+
and otherwise streams raw frames to an external FFmpeg subprocess over stdin
|
|
120
|
+
with stderr redirected to a disk log, so the pipe buffer cannot deadlock.
|
|
121
|
+
|
|
122
|
+
PyAV is preferred because it is the only route that works everywhere the
|
|
123
|
+
library runs: no ffmpeg build ships a `win_arm64` wheel on PyPI, so on Windows
|
|
124
|
+
ARM64 the subprocess path depends on an ffmpeg the user installed themselves.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, output_path: str | Path, fps: int = 30):
|
|
128
|
+
self.output_path = Path(output_path)
|
|
129
|
+
self.fps = fps
|
|
130
|
+
self.interval = 1.0 / fps
|
|
131
|
+
self.stop_event = threading.Event()
|
|
132
|
+
self.thread: threading.Thread | None = None
|
|
133
|
+
self._proc: subprocess.Popen | None = None
|
|
134
|
+
self._stderr_file = None
|
|
135
|
+
self._frame_count = 0
|
|
136
|
+
self._ffmpeg_exe = resolve_ffmpeg_exe()
|
|
137
|
+
# PyAV encoding state
|
|
138
|
+
self._av = None
|
|
139
|
+
self._container = None
|
|
140
|
+
self._stream = None
|
|
141
|
+
self._t0 = 0.0
|
|
142
|
+
self._size: tuple[int, int] = (0, 0)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def backend(self) -> str | None:
|
|
146
|
+
""" "pyav", "ffmpeg", or None when recording is not running."""
|
|
147
|
+
if self._container is not None:
|
|
148
|
+
return "pyav"
|
|
149
|
+
if self._proc is not None:
|
|
150
|
+
return "ffmpeg"
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
def _start_pyav(self, w: int, h: int) -> bool:
|
|
154
|
+
try:
|
|
155
|
+
import av
|
|
156
|
+
except ImportError:
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
codec = "libx264" if self.output_path.suffix.lower() == ".mp4" else "libvpx-vp9"
|
|
160
|
+
container = None
|
|
161
|
+
try:
|
|
162
|
+
container = av.open(str(self.output_path), mode="w")
|
|
163
|
+
stream = container.add_stream(codec, rate=self.fps)
|
|
164
|
+
stream.width, stream.height = w, h
|
|
165
|
+
stream.pix_fmt = "yuv420p"
|
|
166
|
+
# Wall-clock presentation timestamps in milliseconds. Screen capture
|
|
167
|
+
# rarely sustains the nominal frame rate, and encoding at a fixed rate
|
|
168
|
+
# would then play the recording back faster than the run happened.
|
|
169
|
+
stream.codec_context.time_base = Fraction(1, 1000)
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
logger.warning(f"PyAV encoder unavailable ({type(exc).__name__}): {exc}")
|
|
172
|
+
if container is not None:
|
|
173
|
+
try:
|
|
174
|
+
container.close()
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
self._av = av
|
|
180
|
+
self._container = container
|
|
181
|
+
self._stream = stream
|
|
182
|
+
self._size = (w, h)
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
def start(self) -> bool:
|
|
186
|
+
self.output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
|
|
188
|
+
# libx264 requires even dimensions; odd desktop resolutions are rare but real.
|
|
189
|
+
w = user32.GetSystemMetrics(0) & ~1
|
|
190
|
+
h = user32.GetSystemMetrics(1) & ~1
|
|
191
|
+
|
|
192
|
+
if self._start_pyav(w, h):
|
|
193
|
+
self.stop_event.clear()
|
|
194
|
+
self._frame_count = 0
|
|
195
|
+
self._t0 = time.monotonic()
|
|
196
|
+
self.thread = threading.Thread(target=self._record_loop, daemon=True)
|
|
197
|
+
self.thread.start()
|
|
198
|
+
logger.info(
|
|
199
|
+
f"ContinuousRecorder started via PyAV (streaming to {self.output_path} "
|
|
200
|
+
f"@ {self.fps} FPS)"
|
|
201
|
+
)
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
if not self._ffmpeg_exe:
|
|
205
|
+
logger.warning(
|
|
206
|
+
"No FFmpeg available: PyAV is not installed and no ffmpeg executable was "
|
|
207
|
+
"found. Install the video extra (pip install 'wintegrate[video]') or put "
|
|
208
|
+
"ffmpeg on PATH. ContinuousRecorder video disabled."
|
|
209
|
+
)
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
return self._start_ffmpeg_subprocess()
|
|
213
|
+
|
|
214
|
+
def _start_ffmpeg_subprocess(self) -> bool:
|
|
215
|
+
stderr_log_path = self.output_path.with_suffix(".ffmpeg.log")
|
|
216
|
+
self._stderr_file = open(stderr_log_path, "w", encoding="utf-8")
|
|
217
|
+
|
|
218
|
+
w = user32.GetSystemMetrics(0)
|
|
219
|
+
h = user32.GetSystemMetrics(1)
|
|
220
|
+
|
|
221
|
+
cmd = [
|
|
222
|
+
self._ffmpeg_exe,
|
|
223
|
+
"-y",
|
|
224
|
+
"-f",
|
|
225
|
+
"rawvideo",
|
|
226
|
+
"-vcodec",
|
|
227
|
+
"rawvideo",
|
|
228
|
+
"-s",
|
|
229
|
+
f"{w}x{h}",
|
|
230
|
+
"-pix_fmt",
|
|
231
|
+
"rgb24",
|
|
232
|
+
"-r",
|
|
233
|
+
str(self.fps),
|
|
234
|
+
"-i",
|
|
235
|
+
"-",
|
|
236
|
+
"-c:v",
|
|
237
|
+
"libx264" if self.output_path.suffix.lower() == ".mp4" else "libvpx-vp9",
|
|
238
|
+
"-pix_fmt",
|
|
239
|
+
"yuv420p",
|
|
240
|
+
str(self.output_path),
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
self._proc = subprocess.Popen(
|
|
245
|
+
cmd,
|
|
246
|
+
stdin=subprocess.PIPE,
|
|
247
|
+
stdout=subprocess.DEVNULL,
|
|
248
|
+
stderr=self._stderr_file,
|
|
249
|
+
)
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
self._stderr_file.close()
|
|
252
|
+
logger.error(f"Failed to start FFmpeg subprocess ({type(exc).__name__}): {exc}")
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
self.stop_event.clear()
|
|
256
|
+
self._frame_count = 0
|
|
257
|
+
self.thread = threading.Thread(target=self._record_loop, daemon=True)
|
|
258
|
+
self.thread.start()
|
|
259
|
+
logger.info(
|
|
260
|
+
f"ContinuousRecorder started via FFmpeg subprocess "
|
|
261
|
+
f"(streaming to {self.output_path} @ {self.fps} FPS)"
|
|
262
|
+
)
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
def _encode_pyav_frame(self, img) -> None:
|
|
266
|
+
w, h = self._size
|
|
267
|
+
frame = self._av.VideoFrame.from_image(img)
|
|
268
|
+
if (frame.width, frame.height) != (w, h):
|
|
269
|
+
frame = frame.reformat(w, h, "yuv420p")
|
|
270
|
+
else:
|
|
271
|
+
frame = frame.reformat(format="yuv420p")
|
|
272
|
+
frame.pts = int((time.monotonic() - self._t0) * 1000)
|
|
273
|
+
frame.time_base = Fraction(1, 1000)
|
|
274
|
+
for packet in self._stream.encode(frame):
|
|
275
|
+
self._container.mux(packet)
|
|
276
|
+
|
|
277
|
+
def _record_loop(self):
|
|
278
|
+
attach_to_input_desktop()
|
|
279
|
+
while not self.stop_event.is_set():
|
|
280
|
+
t0 = time.monotonic()
|
|
281
|
+
try:
|
|
282
|
+
img = capture_screen_image()
|
|
283
|
+
if self._container is not None:
|
|
284
|
+
self._encode_pyav_frame(img)
|
|
285
|
+
self._frame_count += 1
|
|
286
|
+
elif self._proc and self._proc.stdin:
|
|
287
|
+
self._proc.stdin.write(img.tobytes())
|
|
288
|
+
self._frame_count += 1
|
|
289
|
+
except (BrokenPipeError, OSError) as exc:
|
|
290
|
+
logger.error(f"FFmpeg stdin pipe broken ({type(exc).__name__}): {exc}")
|
|
291
|
+
break
|
|
292
|
+
except Exception as exc:
|
|
293
|
+
logger.debug(f"Screen capture frame skipped ({type(exc).__name__}): {exc}")
|
|
294
|
+
|
|
295
|
+
elapsed = time.monotonic() - t0
|
|
296
|
+
sleep_time = max(0.001, self.interval - elapsed)
|
|
297
|
+
time.sleep(sleep_time)
|
|
298
|
+
|
|
299
|
+
def stop(self, timeout: float = 5.0) -> int:
|
|
300
|
+
self.stop_event.set()
|
|
301
|
+
if self.thread:
|
|
302
|
+
self.thread.join(timeout=timeout)
|
|
303
|
+
|
|
304
|
+
if self._container is not None:
|
|
305
|
+
# Flush the encoder before closing, or the tail of the recording — the
|
|
306
|
+
# part covering the failure being diagnosed — never reaches the file.
|
|
307
|
+
try:
|
|
308
|
+
for packet in self._stream.encode(None):
|
|
309
|
+
self._container.mux(packet)
|
|
310
|
+
except Exception as exc:
|
|
311
|
+
logger.debug(f"PyAV encoder flush warning ({type(exc).__name__}): {exc}")
|
|
312
|
+
try:
|
|
313
|
+
self._container.close()
|
|
314
|
+
except Exception as exc:
|
|
315
|
+
logger.debug(f"PyAV container close warning ({type(exc).__name__}): {exc}")
|
|
316
|
+
self._container = None
|
|
317
|
+
self._stream = None
|
|
318
|
+
logger.info(f"ContinuousRecorder stopped. Total frames recorded: {self._frame_count}")
|
|
319
|
+
return self._frame_count
|
|
320
|
+
|
|
321
|
+
if self._proc:
|
|
322
|
+
try:
|
|
323
|
+
if self._proc.stdin:
|
|
324
|
+
try:
|
|
325
|
+
self._proc.stdin.close()
|
|
326
|
+
except (OSError, BrokenPipeError):
|
|
327
|
+
pass
|
|
328
|
+
self._proc.wait(timeout=timeout)
|
|
329
|
+
except (subprocess.TimeoutExpired, OSError, BrokenPipeError):
|
|
330
|
+
try:
|
|
331
|
+
self._proc.kill()
|
|
332
|
+
except Exception:
|
|
333
|
+
pass
|
|
334
|
+
except Exception as exc:
|
|
335
|
+
logger.debug(
|
|
336
|
+
f"ContinuousRecorder process cleanup warning ({type(exc).__name__}): {exc}"
|
|
337
|
+
)
|
|
338
|
+
finally:
|
|
339
|
+
self._proc = None
|
|
340
|
+
|
|
341
|
+
if self._stderr_file:
|
|
342
|
+
try:
|
|
343
|
+
self._stderr_file.close()
|
|
344
|
+
except Exception:
|
|
345
|
+
pass
|
|
346
|
+
self._stderr_file = None
|
|
347
|
+
|
|
348
|
+
logger.info(f"ContinuousRecorder stopped. Total frames recorded: {self._frame_count}")
|
|
349
|
+
return self._frame_count
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass
|
|
353
|
+
class WindowSnapshot:
|
|
354
|
+
hwnd: int
|
|
355
|
+
title: str
|
|
356
|
+
class_name: str
|
|
357
|
+
pid: int
|
|
358
|
+
is_visible: bool
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@dataclass
|
|
362
|
+
class CensusDiff:
|
|
363
|
+
added: list[WindowSnapshot] = field(default_factory=list)
|
|
364
|
+
removed: list[WindowSnapshot] = field(default_factory=list)
|
|
365
|
+
persisted: list[WindowSnapshot] = field(default_factory=list)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class WindowCensus:
|
|
369
|
+
"""Takes instant snapshots of all top-level desktop windows and diffs them."""
|
|
370
|
+
|
|
371
|
+
@staticmethod
|
|
372
|
+
def capture() -> list[WindowSnapshot]:
|
|
373
|
+
attach_to_input_desktop()
|
|
374
|
+
snapshots: list[WindowSnapshot] = []
|
|
375
|
+
|
|
376
|
+
def enum_proc(hwnd, _):
|
|
377
|
+
is_vis = bool(user32.IsWindowVisible(hwnd))
|
|
378
|
+
title = get_window_title(hwnd)
|
|
379
|
+
cls = get_window_class(hwnd)
|
|
380
|
+
pid = get_window_pid(hwnd)
|
|
381
|
+
snapshots.append(
|
|
382
|
+
WindowSnapshot(
|
|
383
|
+
hwnd=hwnd,
|
|
384
|
+
title=title,
|
|
385
|
+
class_name=cls,
|
|
386
|
+
pid=pid,
|
|
387
|
+
is_visible=is_vis,
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
return True
|
|
391
|
+
|
|
392
|
+
cb = WNDENUMPROC(enum_proc)
|
|
393
|
+
h_desk = get_input_desktop_handle()
|
|
394
|
+
if h_desk:
|
|
395
|
+
user32.EnumDesktopWindows(h_desk, cb, 0)
|
|
396
|
+
else:
|
|
397
|
+
user32.EnumWindows(cb, 0)
|
|
398
|
+
return snapshots
|
|
399
|
+
|
|
400
|
+
@classmethod
|
|
401
|
+
def diff(cls, before: list[WindowSnapshot], after: list[WindowSnapshot]) -> CensusDiff:
|
|
402
|
+
before_map = {w.hwnd: w for w in before}
|
|
403
|
+
after_map = {w.hwnd: w for w in after}
|
|
404
|
+
|
|
405
|
+
added = [w for hwnd, w in after_map.items() if hwnd not in before_map]
|
|
406
|
+
removed = [w for hwnd, w in before_map.items() if hwnd not in after_map]
|
|
407
|
+
persisted = [w for hwnd, w in after_map.items() if hwnd in before_map]
|
|
408
|
+
|
|
409
|
+
return CensusDiff(added=added, removed=removed, persisted=persisted)
|