VirtualDesktop 1.0.1__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.
- VirtualDesktop/__init__.py +121 -0
- VirtualDesktop/__main__.py +10 -0
- VirtualDesktop/_bundle.py +369 -0
- VirtualDesktop/_compat.py +576 -0
- VirtualDesktop/_devices.py +335 -0
- VirtualDesktop/_download.py +462 -0
- VirtualDesktop/_helper.py +31 -0
- VirtualDesktop/_log.py +85 -0
- VirtualDesktop/_process.py +912 -0
- VirtualDesktop/_sigcheck.ps1 +32 -0
- VirtualDesktop/_sigcheck.py +321 -0
- VirtualDesktop/_winapi.py +714 -0
- VirtualDesktop/capture.py +671 -0
- VirtualDesktop/cli.py +673 -0
- VirtualDesktop/config.py +255 -0
- VirtualDesktop/desktop.py +231 -0
- VirtualDesktop/desktop_capture.py +159 -0
- VirtualDesktop/desktop_session.py +533 -0
- VirtualDesktop/display_setup.py +790 -0
- VirtualDesktop/driver.py +831 -0
- VirtualDesktop/driver_manifest.json +118 -0
- VirtualDesktop/errors.py +211 -0
- VirtualDesktop/manifest.py +310 -0
- VirtualDesktop/session.py +514 -0
- VirtualDesktop/window.py +869 -0
- virtualdesktop-1.0.1.dist-info/METADATA +307 -0
- virtualdesktop-1.0.1.dist-info/RECORD +31 -0
- virtualdesktop-1.0.1.dist-info/WHEEL +5 -0
- virtualdesktop-1.0.1.dist-info/entry_points.txt +2 -0
- virtualdesktop-1.0.1.dist-info/licenses/LICENSE +21 -0
- virtualdesktop-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
"""Capture engine: ``PrintWindow`` + ``PW_RENDERFULLCONTENT`` with real fallbacks.
|
|
2
|
+
|
|
3
|
+
Offscreen capture is not one API call, it is a decision tree, because no single Win32
|
|
4
|
+
method works on every window class:
|
|
5
|
+
|
|
6
|
+
=============== =====================================================================
|
|
7
|
+
method behaviour
|
|
8
|
+
=============== =====================================================================
|
|
9
|
+
printwindow ``PrintWindow(hwnd, dc, PW_RENDERFULLCONTENT)``. Best result for most
|
|
10
|
+
Win32/WPF windows, *including completely offscreen ones* (the window
|
|
11
|
+
paints itself into our DC). Fails or returns black for some
|
|
12
|
+
DirectComposition/DirectX surfaces.
|
|
13
|
+
printwindow_plain ``PW_RENDERFULLCONTENT`` was added in Windows 8.1; older systems or
|
|
14
|
+
non-composited windows need the plain variant.
|
|
15
|
+
wm_print ``WM_PRINT``/``WM_PRINTCLIENT`` via ``SendMessageTimeout``. Slow, but it
|
|
16
|
+
works on a few legacy controls that ignore ``PrintWindow``.
|
|
17
|
+
bitblt ``BitBlt(SRCCOPY)`` from the window DC. Content-accurate for GDI windows
|
|
18
|
+
**only while the window is actually rendered**, so it is the last resort
|
|
19
|
+
for offscreen mounts.
|
|
20
|
+
=============== =====================================================================
|
|
21
|
+
|
|
22
|
+
Every result is validated (see :func:`analyze_image`): a uniform, all-black or all-white
|
|
23
|
+
frame is treated as a *failure*, not as a screenshot, and the engine moves on to the next
|
|
24
|
+
method. That check is what turns "sometimes I get a black PNG" into a deterministic
|
|
25
|
+
fallback chain.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import ctypes
|
|
31
|
+
import time
|
|
32
|
+
from ctypes import wintypes
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
35
|
+
|
|
36
|
+
from ._log import get_logger
|
|
37
|
+
from ._winapi import (
|
|
38
|
+
IS_WINDOWS,
|
|
39
|
+
dpi_awareness_scope,
|
|
40
|
+
is_owned_window,
|
|
41
|
+
require_windows,
|
|
42
|
+
)
|
|
43
|
+
from .errors import CaptureError
|
|
44
|
+
|
|
45
|
+
log = get_logger("capture")
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"DEFAULT_METHODS",
|
|
49
|
+
"Frame",
|
|
50
|
+
"ImageAnalysis",
|
|
51
|
+
"analyze_image",
|
|
52
|
+
"CaptureEngine",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
DEFAULT_METHODS: Tuple[str, ...] = ("printwindow", "printwindow_plain", "wm_print", "bitblt")
|
|
56
|
+
|
|
57
|
+
if IS_WINDOWS: # pragma: no branch
|
|
58
|
+
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
59
|
+
gdi32 = ctypes.WinDLL("gdi32", use_last_error=True)
|
|
60
|
+
try:
|
|
61
|
+
dwmapi = ctypes.WinDLL("dwmapi", use_last_error=True)
|
|
62
|
+
except OSError: # pragma: no cover
|
|
63
|
+
dwmapi = None
|
|
64
|
+
else: # pragma: no cover
|
|
65
|
+
user32 = gdi32 = dwmapi = None
|
|
66
|
+
|
|
67
|
+
PW_CLIENTONLY = 0x00000001
|
|
68
|
+
PW_RENDERFULLCONTENT = 0x00000002
|
|
69
|
+
BI_RGB = 0
|
|
70
|
+
DIB_RGB_COLORS = 0
|
|
71
|
+
SRCCOPY = 0x00CC0020
|
|
72
|
+
CAPTUREBLT = 0x40000000
|
|
73
|
+
WM_PRINT = 0x0317
|
|
74
|
+
WM_PRINTCLIENT = 0x0318
|
|
75
|
+
PRF_CLIENT = 0x00000004
|
|
76
|
+
PRF_CHILDREN = 0x00000010
|
|
77
|
+
PRF_ERASEBKGND = 0x00000008
|
|
78
|
+
PRF_NONCLIENT = 0x00000002
|
|
79
|
+
SMTO_ABORTIFHUNG = 0x0002
|
|
80
|
+
_GA_PARENT = 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _RECT(ctypes.Structure):
|
|
84
|
+
_fields_ = [
|
|
85
|
+
("left", wintypes.LONG),
|
|
86
|
+
("top", wintypes.LONG),
|
|
87
|
+
("right", wintypes.LONG),
|
|
88
|
+
("bottom", wintypes.LONG),
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _POINT(ctypes.Structure):
|
|
93
|
+
_fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _BITMAPINFOHEADER(ctypes.Structure):
|
|
97
|
+
_fields_ = [
|
|
98
|
+
("biSize", wintypes.DWORD),
|
|
99
|
+
("biWidth", wintypes.LONG),
|
|
100
|
+
("biHeight", wintypes.LONG),
|
|
101
|
+
("biPlanes", wintypes.WORD),
|
|
102
|
+
("biBitCount", wintypes.WORD),
|
|
103
|
+
("biCompression", wintypes.DWORD),
|
|
104
|
+
("biSizeImage", wintypes.DWORD),
|
|
105
|
+
("biXPelsPerMeter", wintypes.LONG),
|
|
106
|
+
("biYPelsPerMeter", wintypes.LONG),
|
|
107
|
+
("biClrUsed", wintypes.DWORD),
|
|
108
|
+
("biClrImportant", wintypes.DWORD),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if IS_WINDOWS: # pragma: no branch
|
|
113
|
+
user32.PrintWindow.argtypes = [wintypes.HWND, wintypes.HDC, wintypes.UINT]
|
|
114
|
+
user32.PrintWindow.restype = wintypes.BOOL
|
|
115
|
+
user32.GetWindowDC.argtypes = [wintypes.HWND]
|
|
116
|
+
user32.GetWindowDC.restype = wintypes.HDC
|
|
117
|
+
user32.GetDC.argtypes = [wintypes.HWND]
|
|
118
|
+
user32.GetDC.restype = wintypes.HDC
|
|
119
|
+
user32.ReleaseDC.argtypes = [wintypes.HWND, wintypes.HDC]
|
|
120
|
+
user32.ReleaseDC.restype = ctypes.c_int
|
|
121
|
+
user32.GetClientRect.argtypes = [wintypes.HWND, ctypes.POINTER(_RECT)]
|
|
122
|
+
user32.GetClientRect.restype = wintypes.BOOL
|
|
123
|
+
user32.GetWindowRect.argtypes = [wintypes.HWND, ctypes.POINTER(_RECT)]
|
|
124
|
+
user32.GetWindowRect.restype = wintypes.BOOL
|
|
125
|
+
user32.ClientToScreen.argtypes = [wintypes.HWND, ctypes.POINTER(_POINT)]
|
|
126
|
+
user32.ClientToScreen.restype = wintypes.BOOL
|
|
127
|
+
user32.IsWindow.argtypes = [wintypes.HWND]
|
|
128
|
+
user32.IsWindow.restype = wintypes.BOOL
|
|
129
|
+
user32.IsWindowVisible.argtypes = [wintypes.HWND]
|
|
130
|
+
user32.IsWindowVisible.restype = wintypes.BOOL
|
|
131
|
+
user32.IsIconic.argtypes = [wintypes.HWND]
|
|
132
|
+
user32.IsIconic.restype = wintypes.BOOL
|
|
133
|
+
user32.SendMessageTimeoutW.argtypes = [
|
|
134
|
+
wintypes.HWND,
|
|
135
|
+
wintypes.UINT,
|
|
136
|
+
wintypes.WPARAM,
|
|
137
|
+
wintypes.LPARAM,
|
|
138
|
+
wintypes.UINT,
|
|
139
|
+
wintypes.UINT,
|
|
140
|
+
ctypes.POINTER(ctypes.c_void_p),
|
|
141
|
+
]
|
|
142
|
+
user32.SendMessageTimeoutW.restype = wintypes.LPARAM
|
|
143
|
+
gdi32.CreateCompatibleDC.argtypes = [wintypes.HDC]
|
|
144
|
+
gdi32.CreateCompatibleDC.restype = wintypes.HDC
|
|
145
|
+
gdi32.CreateCompatibleBitmap.argtypes = [wintypes.HDC, ctypes.c_int, ctypes.c_int]
|
|
146
|
+
gdi32.CreateCompatibleBitmap.restype = wintypes.HBITMAP
|
|
147
|
+
gdi32.CreateDIBSection.argtypes = [
|
|
148
|
+
wintypes.HDC,
|
|
149
|
+
ctypes.POINTER(_BITMAPINFOHEADER),
|
|
150
|
+
wintypes.UINT,
|
|
151
|
+
ctypes.POINTER(ctypes.c_void_p),
|
|
152
|
+
wintypes.HANDLE,
|
|
153
|
+
wintypes.DWORD,
|
|
154
|
+
]
|
|
155
|
+
gdi32.CreateDIBSection.restype = wintypes.HBITMAP
|
|
156
|
+
gdi32.SelectObject.argtypes = [wintypes.HDC, wintypes.HGDIOBJ]
|
|
157
|
+
gdi32.SelectObject.restype = wintypes.HGDIOBJ
|
|
158
|
+
gdi32.DeleteObject.argtypes = [wintypes.HGDIOBJ]
|
|
159
|
+
gdi32.DeleteObject.restype = wintypes.BOOL
|
|
160
|
+
gdi32.DeleteDC.argtypes = [wintypes.HDC]
|
|
161
|
+
gdi32.DeleteDC.restype = wintypes.BOOL
|
|
162
|
+
gdi32.BitBlt.argtypes = [
|
|
163
|
+
wintypes.HDC,
|
|
164
|
+
ctypes.c_int,
|
|
165
|
+
ctypes.c_int,
|
|
166
|
+
ctypes.c_int,
|
|
167
|
+
ctypes.c_int,
|
|
168
|
+
wintypes.HDC,
|
|
169
|
+
ctypes.c_int,
|
|
170
|
+
ctypes.c_int,
|
|
171
|
+
wintypes.DWORD,
|
|
172
|
+
]
|
|
173
|
+
gdi32.BitBlt.restype = wintypes.BOOL
|
|
174
|
+
gdi32.GetDIBits.argtypes = [
|
|
175
|
+
wintypes.HDC,
|
|
176
|
+
wintypes.HBITMAP,
|
|
177
|
+
wintypes.UINT,
|
|
178
|
+
wintypes.UINT,
|
|
179
|
+
ctypes.c_void_p,
|
|
180
|
+
ctypes.POINTER(_BITMAPINFOHEADER),
|
|
181
|
+
wintypes.UINT,
|
|
182
|
+
]
|
|
183
|
+
gdi32.GetDIBits.restype = ctypes.c_int
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass
|
|
187
|
+
class ImageAnalysis:
|
|
188
|
+
"""Cheap statistical validation of a captured frame."""
|
|
189
|
+
|
|
190
|
+
width: int = 0
|
|
191
|
+
height: int = 0
|
|
192
|
+
variance: float = 0.0
|
|
193
|
+
mean: float = 0.0
|
|
194
|
+
max_value: int = 0
|
|
195
|
+
dominant_ratio: float = 0.0
|
|
196
|
+
uniform: bool = False
|
|
197
|
+
blank: bool = False
|
|
198
|
+
reason: str = ""
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def usable(self) -> bool:
|
|
202
|
+
return not self.blank
|
|
203
|
+
|
|
204
|
+
def to_dict(self) -> dict:
|
|
205
|
+
return {
|
|
206
|
+
"width": self.width,
|
|
207
|
+
"height": self.height,
|
|
208
|
+
"variance": round(self.variance, 4),
|
|
209
|
+
"mean": round(self.mean, 3),
|
|
210
|
+
"max_value": self.max_value,
|
|
211
|
+
"dominant_ratio": round(self.dominant_ratio, 4),
|
|
212
|
+
"uniform": self.uniform,
|
|
213
|
+
"blank": self.blank,
|
|
214
|
+
"reason": self.reason,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def analyze_image(
|
|
219
|
+
image,
|
|
220
|
+
*,
|
|
221
|
+
variance_threshold: float = 0.05,
|
|
222
|
+
dominant_threshold: float = 0.999,
|
|
223
|
+
min_max_value: int = 10,
|
|
224
|
+
) -> ImageAnalysis:
|
|
225
|
+
"""Decide whether a frame carries real content.
|
|
226
|
+
|
|
227
|
+
Three failure signatures are checked, because no single one is sufficient:
|
|
228
|
+
|
|
229
|
+
* **near-black frame** -- ``max(pixel) < min_max_value``. This is the cheap, very reliable
|
|
230
|
+
check used by production WeChat automation (``np.array(img).max() > 10``): any real
|
|
231
|
+
window contains at least one bright pixel, a failed ``PrintWindow`` does not.
|
|
232
|
+
* **uniform surface** -- variance below ``variance_threshold``: nothing was painted at all
|
|
233
|
+
(covers all-white as well as a solid theme colour).
|
|
234
|
+
* **single colour** -- one colour covering at least ``dominant_threshold`` of the pixels
|
|
235
|
+
*and* still lacking structure (variance < 1.0). The variance guard keeps a mostly-white
|
|
236
|
+
window with a little text acceptable.
|
|
237
|
+
"""
|
|
238
|
+
try:
|
|
239
|
+
from PIL import Image, ImageStat
|
|
240
|
+
except ImportError as exc: # pragma: no cover - Pillow is a hard dependency
|
|
241
|
+
raise CaptureError(
|
|
242
|
+
f"Pillow is required for capture validation: {exc}", code="missing-pillow"
|
|
243
|
+
) from exc
|
|
244
|
+
|
|
245
|
+
analysis = ImageAnalysis(width=image.width, height=image.height)
|
|
246
|
+
if image.width == 0 or image.height == 0:
|
|
247
|
+
analysis.blank = True
|
|
248
|
+
analysis.reason = "empty-geometry"
|
|
249
|
+
return analysis
|
|
250
|
+
greyscale = image.convert("L")
|
|
251
|
+
stats = ImageStat.Stat(greyscale)
|
|
252
|
+
analysis.mean = float(stats.mean[0])
|
|
253
|
+
analysis.variance = float(stats.var[0])
|
|
254
|
+
try:
|
|
255
|
+
analysis.max_value = int(stats.extrema[0][1])
|
|
256
|
+
except Exception: # pragma: no cover - defensive
|
|
257
|
+
analysis.max_value = 255
|
|
258
|
+
try:
|
|
259
|
+
channels = image.split()[:3]
|
|
260
|
+
peak = 0
|
|
261
|
+
total = 0
|
|
262
|
+
for channel in channels:
|
|
263
|
+
histogram = channel.histogram()
|
|
264
|
+
peak = max(peak, max(histogram))
|
|
265
|
+
total = max(total, sum(histogram))
|
|
266
|
+
analysis.dominant_ratio = (peak / total) if total else 0.0
|
|
267
|
+
except Exception: # pragma: no cover - defensive
|
|
268
|
+
analysis.dominant_ratio = 0.0
|
|
269
|
+
|
|
270
|
+
if analysis.variance < variance_threshold:
|
|
271
|
+
analysis.uniform = True
|
|
272
|
+
analysis.blank = True
|
|
273
|
+
analysis.reason = f"uniform(variance={analysis.variance:.4f})"
|
|
274
|
+
return analysis
|
|
275
|
+
if analysis.max_value < min_max_value:
|
|
276
|
+
# Cheap, very reliable failure signature used by production WeChat automation
|
|
277
|
+
# (``np.array(img).max() > 10``): a real window always contains a bright pixel.
|
|
278
|
+
analysis.blank = True
|
|
279
|
+
analysis.reason = f"near-black(max={analysis.max_value})"
|
|
280
|
+
return analysis
|
|
281
|
+
if analysis.dominant_ratio >= dominant_threshold and analysis.variance < 1.0:
|
|
282
|
+
# A single colour across (almost) the whole frame with no structure.
|
|
283
|
+
analysis.blank = True
|
|
284
|
+
analysis.reason = f"single-colour({analysis.dominant_ratio:.4f})"
|
|
285
|
+
return analysis
|
|
286
|
+
return analysis
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@dataclass
|
|
290
|
+
class Frame:
|
|
291
|
+
"""A captured frame plus everything needed to explain how it was produced."""
|
|
292
|
+
|
|
293
|
+
image: object # PIL.Image.Image (kept untyped to avoid a hard import at module load)
|
|
294
|
+
hwnd: int = 0
|
|
295
|
+
method: str = ""
|
|
296
|
+
region: Tuple[int, int, int, int] = (0, 0, 0, 0)
|
|
297
|
+
client_only: bool = True
|
|
298
|
+
dpi_aware: bool = False
|
|
299
|
+
attempts: List[dict] = field(default_factory=list)
|
|
300
|
+
analysis: Optional[ImageAnalysis] = None
|
|
301
|
+
duration: float = 0.0
|
|
302
|
+
|
|
303
|
+
@property
|
|
304
|
+
def size(self) -> Tuple[int, int]:
|
|
305
|
+
return (self.image.width, self.image.height) # type: ignore[attr-defined]
|
|
306
|
+
|
|
307
|
+
def save(self, path, **kwargs) -> str:
|
|
308
|
+
"""Save the frame (PNG by default) and return the written path."""
|
|
309
|
+
target = str(path)
|
|
310
|
+
self.image.save(target, **kwargs) # type: ignore[attr-defined]
|
|
311
|
+
return target
|
|
312
|
+
|
|
313
|
+
def to_dict(self) -> dict:
|
|
314
|
+
return {
|
|
315
|
+
"hwnd": self.hwnd,
|
|
316
|
+
"method": self.method,
|
|
317
|
+
"region": list(self.region),
|
|
318
|
+
"client_only": self.client_only,
|
|
319
|
+
"dpi_aware": self.dpi_aware,
|
|
320
|
+
"size": list(self.size),
|
|
321
|
+
"attempts": list(self.attempts),
|
|
322
|
+
"analysis": self.analysis.to_dict() if self.analysis else None,
|
|
323
|
+
"duration": round(self.duration, 4),
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _virtual_screen() -> Tuple[int, int, int, int]:
|
|
328
|
+
SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN = 76, 77, 78, 79
|
|
329
|
+
return (
|
|
330
|
+
user32.GetSystemMetrics(SM_XVIRTUALSCREEN),
|
|
331
|
+
user32.GetSystemMetrics(SM_YVIRTUALSCREEN),
|
|
332
|
+
user32.GetSystemMetrics(SM_CXVIRTUALSCREEN),
|
|
333
|
+
user32.GetSystemMetrics(SM_CYVIRTUALSCREEN),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class _MemoryBitmap:
|
|
338
|
+
"""A top-down 32-bit DIB section plus its DC; always released."""
|
|
339
|
+
|
|
340
|
+
def __init__(self, width: int, height: int):
|
|
341
|
+
self.width = int(width)
|
|
342
|
+
self.height = int(height)
|
|
343
|
+
self._screen_dc = user32.GetDC(None)
|
|
344
|
+
if not self._screen_dc:
|
|
345
|
+
raise CaptureError(
|
|
346
|
+
f"GetDC(NULL) failed with Win32 error {ctypes.get_last_error()}",
|
|
347
|
+
code="getdc-failed",
|
|
348
|
+
)
|
|
349
|
+
self.dc = gdi32.CreateCompatibleDC(self._screen_dc)
|
|
350
|
+
if not self.dc:
|
|
351
|
+
user32.ReleaseDC(None, self._screen_dc)
|
|
352
|
+
raise CaptureError("CreateCompatibleDC failed", code="createdc-failed")
|
|
353
|
+
header = _BITMAPINFOHEADER()
|
|
354
|
+
header.biSize = ctypes.sizeof(_BITMAPINFOHEADER)
|
|
355
|
+
header.biWidth = self.width
|
|
356
|
+
header.biHeight = -self.height # negative => top-down rows
|
|
357
|
+
header.biPlanes = 1
|
|
358
|
+
header.biBitCount = 32
|
|
359
|
+
header.biCompression = BI_RGB
|
|
360
|
+
self.bits = ctypes.c_void_p()
|
|
361
|
+
self._header = header
|
|
362
|
+
self.bitmap = gdi32.CreateDIBSection(
|
|
363
|
+
self.dc, ctypes.byref(header), DIB_RGB_COLORS, ctypes.byref(self.bits), None, 0
|
|
364
|
+
)
|
|
365
|
+
if not self.bitmap:
|
|
366
|
+
self.close()
|
|
367
|
+
raise CaptureError("CreateDIBSection failed", code="createdib-failed")
|
|
368
|
+
self._previous = gdi32.SelectObject(self.dc, self.bitmap)
|
|
369
|
+
|
|
370
|
+
def buffer(self) -> ctypes.Array:
|
|
371
|
+
size = self.width * self.height * 4
|
|
372
|
+
return ctypes.cast(self.bits, ctypes.POINTER(ctypes.c_char * size)).contents
|
|
373
|
+
|
|
374
|
+
def to_image(self):
|
|
375
|
+
from PIL import Image
|
|
376
|
+
|
|
377
|
+
data = bytes(self.buffer())
|
|
378
|
+
return Image.frombuffer("RGB", (self.width, self.height), data, "raw", "BGRX", 0, 1)
|
|
379
|
+
|
|
380
|
+
def close(self) -> None:
|
|
381
|
+
if getattr(self, "bitmap", None):
|
|
382
|
+
if getattr(self, "_previous", None):
|
|
383
|
+
gdi32.SelectObject(self.dc, self._previous)
|
|
384
|
+
self._previous = None
|
|
385
|
+
gdi32.DeleteObject(self.bitmap)
|
|
386
|
+
self.bitmap = None
|
|
387
|
+
if getattr(self, "dc", None):
|
|
388
|
+
gdi32.DeleteDC(self.dc)
|
|
389
|
+
self.dc = None
|
|
390
|
+
if getattr(self, "_screen_dc", None):
|
|
391
|
+
user32.ReleaseDC(None, self._screen_dc)
|
|
392
|
+
self._screen_dc = None
|
|
393
|
+
|
|
394
|
+
def __enter__(self) -> "_MemoryBitmap":
|
|
395
|
+
return self
|
|
396
|
+
|
|
397
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
398
|
+
self.close()
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
class CaptureEngine:
|
|
402
|
+
"""Runs the capture decision tree and returns the first *usable* frame."""
|
|
403
|
+
|
|
404
|
+
def __init__(
|
|
405
|
+
self,
|
|
406
|
+
*,
|
|
407
|
+
methods: Sequence[str] = DEFAULT_METHODS,
|
|
408
|
+
client_only: bool = True,
|
|
409
|
+
validate: bool = True,
|
|
410
|
+
variance_threshold: float = 0.05,
|
|
411
|
+
dominant_threshold: float = 0.999,
|
|
412
|
+
timeout: float = 5.0,
|
|
413
|
+
allow_blank_fallback: bool = True,
|
|
414
|
+
) -> None:
|
|
415
|
+
unknown = [m for m in methods if m not in DEFAULT_METHODS]
|
|
416
|
+
if unknown:
|
|
417
|
+
raise CaptureError(
|
|
418
|
+
f"unknown capture method(s): {', '.join(unknown)}",
|
|
419
|
+
code="unknown-method",
|
|
420
|
+
hint=f"Available methods: {', '.join(DEFAULT_METHODS)}",
|
|
421
|
+
)
|
|
422
|
+
self.methods = tuple(methods)
|
|
423
|
+
self.client_only = client_only
|
|
424
|
+
self.validate = validate
|
|
425
|
+
self.variance_threshold = variance_threshold
|
|
426
|
+
self.dominant_threshold = dominant_threshold
|
|
427
|
+
self.timeout = timeout
|
|
428
|
+
self.allow_blank_fallback = allow_blank_fallback
|
|
429
|
+
self._dpi_entered = False
|
|
430
|
+
|
|
431
|
+
# -- public API ------------------------------------------------------------------
|
|
432
|
+
def capture(
|
|
433
|
+
self,
|
|
434
|
+
hwnd: int,
|
|
435
|
+
*,
|
|
436
|
+
client_only: Optional[bool] = None,
|
|
437
|
+
methods: Optional[Sequence[str]] = None,
|
|
438
|
+
region: Optional[Tuple[int, int, int, int]] = None,
|
|
439
|
+
require_content: Optional[bool] = None,
|
|
440
|
+
save_to: Optional[str] = None,
|
|
441
|
+
) -> Frame:
|
|
442
|
+
"""Capture ``hwnd``.
|
|
443
|
+
|
|
444
|
+
``region`` (left, top, right, bottom in *client* coordinates) crops the result,
|
|
445
|
+
which is what OCR pipelines usually want. ``save_to`` writes the frame to disk
|
|
446
|
+
(PNG by default) before returning it.
|
|
447
|
+
"""
|
|
448
|
+
require_windows("CaptureEngine.capture")
|
|
449
|
+
if not user32.IsWindow(hwnd):
|
|
450
|
+
raise CaptureError(
|
|
451
|
+
f"0x{hwnd:X} is not a window (it may have been closed)", code="invalid-hwnd"
|
|
452
|
+
)
|
|
453
|
+
use_client = self.client_only if client_only is None else client_only
|
|
454
|
+
chain = tuple(methods or self.methods)
|
|
455
|
+
must_have_content = self.validate if require_content is None else require_content
|
|
456
|
+
started = time.monotonic()
|
|
457
|
+
attempts: List[dict] = []
|
|
458
|
+
blank_frame: Optional[Frame] = None
|
|
459
|
+
|
|
460
|
+
self.ensure_dpi_aware()
|
|
461
|
+
for method in chain:
|
|
462
|
+
try:
|
|
463
|
+
image, used_region = self._invoke(method, hwnd, use_client)
|
|
464
|
+
except CaptureError as exc:
|
|
465
|
+
attempts.append(
|
|
466
|
+
{"method": method, "ok": False, "error": str(exc), "code": exc.code}
|
|
467
|
+
)
|
|
468
|
+
log.debug("capture method %s failed: %s", method, exc)
|
|
469
|
+
continue
|
|
470
|
+
analysis = (
|
|
471
|
+
analyze_image(
|
|
472
|
+
image,
|
|
473
|
+
variance_threshold=self.variance_threshold,
|
|
474
|
+
dominant_threshold=self.dominant_threshold,
|
|
475
|
+
)
|
|
476
|
+
if self.validate
|
|
477
|
+
else None
|
|
478
|
+
)
|
|
479
|
+
attempts.append(
|
|
480
|
+
{
|
|
481
|
+
"method": method,
|
|
482
|
+
"ok": True,
|
|
483
|
+
"analysis": analysis.to_dict() if analysis else None,
|
|
484
|
+
}
|
|
485
|
+
)
|
|
486
|
+
frame = Frame(
|
|
487
|
+
image=image,
|
|
488
|
+
hwnd=hwnd,
|
|
489
|
+
method=method,
|
|
490
|
+
region=used_region,
|
|
491
|
+
client_only=use_client,
|
|
492
|
+
dpi_aware=self._dpi_entered,
|
|
493
|
+
attempts=attempts,
|
|
494
|
+
analysis=analysis,
|
|
495
|
+
duration=time.monotonic() - started,
|
|
496
|
+
)
|
|
497
|
+
if analysis is None or analysis.usable:
|
|
498
|
+
if region is not None:
|
|
499
|
+
frame.image = self._crop(image, used_region, region)
|
|
500
|
+
if save_to:
|
|
501
|
+
frame.save(save_to)
|
|
502
|
+
return frame
|
|
503
|
+
log.info(
|
|
504
|
+
"capture method %s produced a %s frame; trying the next method",
|
|
505
|
+
method,
|
|
506
|
+
analysis.reason,
|
|
507
|
+
)
|
|
508
|
+
if blank_frame is None:
|
|
509
|
+
blank_frame = frame
|
|
510
|
+
|
|
511
|
+
if blank_frame is not None and self.allow_blank_fallback and not must_have_content:
|
|
512
|
+
log.warning("returning a blank frame because require_content=False")
|
|
513
|
+
return blank_frame
|
|
514
|
+
raise CaptureError(
|
|
515
|
+
"every capture method failed or produced a blank frame for 0x%X" % hwnd,
|
|
516
|
+
code="capture-blank",
|
|
517
|
+
details={
|
|
518
|
+
"attempts": attempts,
|
|
519
|
+
"methods": list(chain),
|
|
520
|
+
"client_only": use_client,
|
|
521
|
+
},
|
|
522
|
+
hint="Hardware-accelerated or DRM-protected windows cannot be captured offscreen. "
|
|
523
|
+
"Try methods=('bitblt',) with the window visible on the virtual display.",
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
def ensure_dpi_aware(self, awareness: str = "per_monitor_v2") -> bool:
|
|
527
|
+
"""Set DPI awareness once per process (never restored: other code benefits too)."""
|
|
528
|
+
if getattr(self, "_dpi_entered", False):
|
|
529
|
+
return True
|
|
530
|
+
if not IS_WINDOWS:
|
|
531
|
+
return False
|
|
532
|
+
scope = dpi_awareness_scope(awareness, restore=False)
|
|
533
|
+
scope.__enter__()
|
|
534
|
+
self._dpi_entered = scope.applied is not None or scope.previous is not None
|
|
535
|
+
log.debug("DPI awareness set to %s", awareness)
|
|
536
|
+
return self._dpi_entered
|
|
537
|
+
|
|
538
|
+
# -- internals -------------------------------------------------------------------
|
|
539
|
+
def _target_rect(self, hwnd: int, client_only: bool) -> Tuple[int, int, int, int, Tuple[int, int]]:
|
|
540
|
+
"""Return ``(left, top, width, height, (client_w, client_h))``."""
|
|
541
|
+
window = _RECT()
|
|
542
|
+
if not user32.GetWindowRect(hwnd, ctypes.byref(window)):
|
|
543
|
+
raise CaptureError("GetWindowRect failed", code="geometry-failed")
|
|
544
|
+
if client_only:
|
|
545
|
+
client = _RECT()
|
|
546
|
+
origin = _POINT(0, 0)
|
|
547
|
+
if user32.GetClientRect(hwnd, ctypes.byref(client)) and user32.ClientToScreen(
|
|
548
|
+
hwnd, ctypes.byref(origin)
|
|
549
|
+
):
|
|
550
|
+
width = int(client.right - client.left)
|
|
551
|
+
height = int(client.bottom - client.top)
|
|
552
|
+
if width > 0 and height > 0:
|
|
553
|
+
return int(origin.x), int(origin.y), width, height, (width, height)
|
|
554
|
+
width = int(window.right - window.left)
|
|
555
|
+
height = int(window.bottom - window.top)
|
|
556
|
+
if width <= 0 or height <= 0:
|
|
557
|
+
raise CaptureError(
|
|
558
|
+
f"window 0x{hwnd:X} has a degenerate geometry {width}x{height}",
|
|
559
|
+
code="degenerate-geometry",
|
|
560
|
+
)
|
|
561
|
+
return int(window.left), int(window.top), width, height, (width, height)
|
|
562
|
+
|
|
563
|
+
def _invoke(self, method: str, hwnd: int, client_only: bool):
|
|
564
|
+
left, top, width, height, _ = self._target_rect(hwnd, client_only)
|
|
565
|
+
region = (left, top, left + width, top + height)
|
|
566
|
+
if method == "printwindow":
|
|
567
|
+
return self._print_window(hwnd, width, height, client_only, PW_RENDERFULLCONTENT), region
|
|
568
|
+
if method == "printwindow_plain":
|
|
569
|
+
return self._print_window(hwnd, width, height, client_only, 0), region
|
|
570
|
+
if method == "wm_print":
|
|
571
|
+
return self._wm_print(hwnd, width, height, client_only), region
|
|
572
|
+
if method == "bitblt":
|
|
573
|
+
return self._bitblt(hwnd, left, top, width, height, client_only), region
|
|
574
|
+
raise CaptureError(f"unknown method {method!r}", code="unknown-method")
|
|
575
|
+
|
|
576
|
+
def _print_window(self, hwnd: int, width: int, height: int, client_only: bool, flags: int):
|
|
577
|
+
"""``PrintWindow`` into a top-down DIB.
|
|
578
|
+
|
|
579
|
+
Validated against the approach used by production WeChat automation
|
|
580
|
+
(``Luofeng-Cloud/WeChat-AI-AutoReply``, ``grab_wechat_window``): ``GetWindowDC`` +
|
|
581
|
+
``CreateCompatibleDC`` + ``CreateCompatibleBitmap(w, h)`` + ``PrintWindow(hwnd, dc, 2)``
|
|
582
|
+
with a plain-``PrintWindow`` fallback, then ``GetDIBits`` into a BGRA buffer. Using a
|
|
583
|
+
**screen** DC for the compatible DC and a DIB section (this implementation) is
|
|
584
|
+
equivalent; the differences that matter are elsewhere and are handled by the caller:
|
|
585
|
+
the bitmap must be the *window's* rectangle (not the monitor's), and the result must be
|
|
586
|
+
rejected when it is effectively empty (a max pixel value near zero).
|
|
587
|
+
"""
|
|
588
|
+
with _MemoryBitmap(width, height) as bitmap:
|
|
589
|
+
flag = flags | (PW_CLIENTONLY if client_only and not flags else 0)
|
|
590
|
+
ctypes.set_last_error(0)
|
|
591
|
+
result = user32.PrintWindow(hwnd, bitmap.dc, flag)
|
|
592
|
+
if not result:
|
|
593
|
+
error = ctypes.get_last_error()
|
|
594
|
+
# A FALSE return is not always fatal: some windows paint anyway. Only
|
|
595
|
+
# treat a *blank* result as failure, which the caller already checks.
|
|
596
|
+
log.debug("PrintWindow(flags=%s) returned 0 (Win32 error %s)", flag, error)
|
|
597
|
+
return bitmap.to_image()
|
|
598
|
+
|
|
599
|
+
def _wm_print(self, hwnd: int, width: int, height: int, client_only: bool):
|
|
600
|
+
with _MemoryBitmap(width, height) as bitmap:
|
|
601
|
+
flags = PRF_CLIENT | PRF_CHILDREN | PRF_ERASEBKGND
|
|
602
|
+
if not client_only:
|
|
603
|
+
flags |= PRF_NONCLIENT
|
|
604
|
+
message = WM_PRINTCLIENT if client_only else WM_PRINT
|
|
605
|
+
result = ctypes.c_void_p()
|
|
606
|
+
ok = user32.SendMessageTimeoutW(
|
|
607
|
+
hwnd, message, int(bitmap.dc), flags, SMTO_ABORTIFHUNG, int(self.timeout * 1000), ctypes.byref(result)
|
|
608
|
+
)
|
|
609
|
+
if not ok:
|
|
610
|
+
raise CaptureError(
|
|
611
|
+
f"WM_PRINT timed out or was refused (Win32 error {ctypes.get_last_error()})",
|
|
612
|
+
code="wm-print-failed",
|
|
613
|
+
)
|
|
614
|
+
return bitmap.to_image()
|
|
615
|
+
|
|
616
|
+
def _bitblt(self, hwnd: int, left: int, top: int, width: int, height: int, client_only: bool):
|
|
617
|
+
with _MemoryBitmap(width, height) as bitmap:
|
|
618
|
+
source = user32.GetDC(hwnd) if client_only else user32.GetWindowDC(hwnd)
|
|
619
|
+
if not source:
|
|
620
|
+
raise CaptureError("GetDC(hwnd) failed", code="getdc-failed")
|
|
621
|
+
try:
|
|
622
|
+
ok = gdi32.BitBlt(
|
|
623
|
+
bitmap.dc, 0, 0, width, height, source, 0, 0, SRCCOPY | CAPTUREBLT
|
|
624
|
+
)
|
|
625
|
+
finally:
|
|
626
|
+
user32.ReleaseDC(hwnd, source)
|
|
627
|
+
if not ok:
|
|
628
|
+
raise CaptureError(
|
|
629
|
+
f"BitBlt from the window DC failed (Win32 error {ctypes.get_last_error()})",
|
|
630
|
+
code="bitblt-failed",
|
|
631
|
+
)
|
|
632
|
+
return bitmap.to_image()
|
|
633
|
+
|
|
634
|
+
@staticmethod
|
|
635
|
+
def _crop(image, used_region, region):
|
|
636
|
+
"""Crop ``region`` (absolute client coordinates) out of the captured frame."""
|
|
637
|
+
offset_x = region[0] - used_region[0]
|
|
638
|
+
offset_y = region[1] - used_region[1]
|
|
639
|
+
box = (
|
|
640
|
+
max(offset_x, 0),
|
|
641
|
+
max(offset_y, 0),
|
|
642
|
+
min(offset_x + (region[2] - region[0]), image.width),
|
|
643
|
+
min(offset_y + (region[3] - region[1]), image.height),
|
|
644
|
+
)
|
|
645
|
+
if box[2] <= box[0] or box[3] <= box[1]:
|
|
646
|
+
raise CaptureError(
|
|
647
|
+
f"requested region {region} lies outside the captured area {used_region}",
|
|
648
|
+
code="region-outside",
|
|
649
|
+
)
|
|
650
|
+
return image.crop(box)
|
|
651
|
+
|
|
652
|
+
# -- diagnostics -----------------------------------------------------------------
|
|
653
|
+
def probe(self, hwnd: int, *, client_only: Optional[bool] = None) -> dict:
|
|
654
|
+
"""Run every method and report per-method outcomes (used by ``check --window``)."""
|
|
655
|
+
results = []
|
|
656
|
+
use_client = self.client_only if client_only is None else client_only
|
|
657
|
+
for method in self.methods:
|
|
658
|
+
entry: Dict[str, object] = {"method": method}
|
|
659
|
+
try:
|
|
660
|
+
frame = self.capture(
|
|
661
|
+
hwnd, client_only=use_client, methods=[method], require_content=True
|
|
662
|
+
)
|
|
663
|
+
entry["ok"] = True
|
|
664
|
+
entry["size"] = list(frame.size)
|
|
665
|
+
entry["analysis"] = frame.analysis.to_dict() if frame.analysis else None
|
|
666
|
+
except CaptureError as exc:
|
|
667
|
+
entry["ok"] = False
|
|
668
|
+
entry["error"] = str(exc)
|
|
669
|
+
entry["code"] = exc.code
|
|
670
|
+
results.append(entry)
|
|
671
|
+
return {"hwnd": hwnd, "client_only": use_client, "methods": results}
|