stevenkhan commited on
Commit
34a8908
·
verified ·
1 Parent(s): d3e22aa

Upload clashcr/core/capture.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. clashcr/core/capture.py +279 -0
clashcr/core/capture.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Emulator screen capture supporting MuMu Player and BlueStacks on Windows.
2
+
3
+ Handles:
4
+ - Dynamic window detection by class name / title
5
+ - Exact client-area capture (excluding borders/title bar)
6
+ - DPI scaling, resize, moved windows, negative monitor coordinates
7
+ - Raw screenshot saving for debugging
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import os
13
+ import time
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Optional, Tuple
17
+
18
+ import numpy as np
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Platform-specific imports
23
+ if os.name == "nt":
24
+ import win32gui
25
+ import win32ui
26
+ import win32con
27
+ from ctypes import windll
28
+ else:
29
+ win32gui = None # type: ignore
30
+
31
+
32
+ @dataclass
33
+ class WindowInfo:
34
+ hwnd: int
35
+ title: str
36
+ class_name: str
37
+ left: int
38
+ top: int
39
+ width: int
40
+ height: int
41
+ client_left: int
42
+ client_top: int
43
+ client_width: int
44
+ client_height: int
45
+ dpi_scale: float
46
+ is_minimized: bool
47
+
48
+
49
+ class EmulatorCapture:
50
+ """Capture emulator windows on Windows using Win32 API."""
51
+
52
+ # Common emulator window class names and title substrings
53
+ EMULATOR_SIGNATURES = [
54
+ ("Qt5QWindowIcon", "MuMu"), # MuMu Player
55
+ ("Qt5QWindowIcon", "Nemu"), # Nemu (MuMu variant)
56
+ ("BlueStacksApp", "BlueStacks"), # BlueStacks
57
+ ("_ctl.Window", "BlueStacks"), # BlueStacks alternative
58
+ ("RenderWindow", None), # Generic render window
59
+ ]
60
+
61
+ def __init__(self, target_title_substring: Optional[str] = None,
62
+ target_class_name: Optional[str] = None,
63
+ fallback_monitor: Optional[dict] = None):
64
+ self.target_title = target_title_substring
65
+ self.target_class = target_class_name
66
+ self.fallback_monitor = fallback_monitor
67
+ self._last_hwnd: Optional[int] = None
68
+ self._last_info: Optional[WindowInfo] = None
69
+
70
+ def _enum_windows(self) -> list:
71
+ if win32gui is None:
72
+ return []
73
+ results = []
74
+ def callback(hwnd, extra):
75
+ if win32gui.IsWindow(hwnd) and win32gui.IsWindowEnabled(hwnd):
76
+ try:
77
+ title = win32gui.GetWindowText(hwnd)
78
+ class_name = win32gui.GetClassName(hwnd)
79
+ results.append((hwnd, title, class_name))
80
+ except Exception:
81
+ pass
82
+ return True
83
+ win32gui.EnumWindows(callback, None)
84
+ return results
85
+
86
+ def find_window(self) -> Optional[WindowInfo]:
87
+ if win32gui is None:
88
+ logger.warning("Win32 API not available; running on non-Windows platform.")
89
+ return None
90
+
91
+ candidates = self._enum_windows()
92
+ best = None
93
+ best_score = 0
94
+
95
+ for hwnd, title, class_name in candidates:
96
+ score = 0
97
+ if self.target_title and self.target_title.lower() in title.lower():
98
+ score += 10
99
+ if self.target_class and self.target_class.lower() == class_name.lower():
100
+ score += 10
101
+
102
+ # Heuristic matching
103
+ for sig_class, sig_title in self.EMULATOR_SIGNATURES:
104
+ if class_name == sig_class:
105
+ score += 5
106
+ if sig_title and sig_title.lower() in title.lower():
107
+ score += 5
108
+
109
+ if score > best_score:
110
+ best = (hwnd, title, class_name)
111
+ best_score = score
112
+
113
+ if best is None:
114
+ return None
115
+
116
+ hwnd, title, class_name = best
117
+ self._last_hwnd = hwnd
118
+ info = self._get_window_info(hwnd)
119
+ self._last_info = info
120
+ return info
121
+
122
+ def _get_window_info(self, hwnd: int) -> WindowInfo:
123
+ rect = win32gui.GetWindowRect(hwnd)
124
+ left, top, right, bottom = rect
125
+ width = right - left
126
+ height = bottom - top
127
+
128
+ # Client area
129
+ client_left, client_top, client_right, client_bottom = win32gui.GetClientRect(hwnd)
130
+ # Map client rect to screen coords
131
+ pt = win32gui.ClientToScreen(hwnd, (client_left, client_top))
132
+ client_left_scr, client_top_scr = pt
133
+ client_width = client_right - client_left
134
+ client_height = client_bottom - client_top
135
+
136
+ # DPI scale
137
+ try:
138
+ dpi = windll.user32.GetDpiForWindow(hwnd)
139
+ dpi_scale = dpi / 96.0
140
+ except Exception:
141
+ dpi_scale = 1.0
142
+
143
+ is_minimized = win32gui.IsIconic(hwnd)
144
+
145
+ return WindowInfo(
146
+ hwnd=hwnd,
147
+ title=win32gui.GetWindowText(hwnd),
148
+ class_name=win32gui.GetClassName(hwnd),
149
+ left=left,
150
+ top=top,
151
+ width=width,
152
+ height=height,
153
+ client_left=client_left_scr,
154
+ client_top=client_top_scr,
155
+ client_width=client_width,
156
+ client_height=client_height,
157
+ dpi_scale=dpi_scale,
158
+ is_minimized=is_minimized,
159
+ )
160
+
161
+ def capture(self, save_raw_path: Optional[str] = None) -> Optional[np.ndarray]:
162
+ """Capture the emulator window and return BGR numpy array."""
163
+ if win32gui is None:
164
+ return self._capture_fallback()
165
+
166
+ info = self.find_window()
167
+ if info is None:
168
+ logger.warning("No emulator window found.")
169
+ return None
170
+
171
+ if info.is_minimized:
172
+ logger.warning("Emulator window is minimized.")
173
+ return None
174
+
175
+ hwnd = info.hwnd
176
+ # Capture client area
177
+ w, h = info.client_width, info.client_height
178
+ if w <= 0 or h <= 0:
179
+ logger.warning("Invalid client area size.")
180
+ return None
181
+
182
+ hwndDC = win32gui.GetWindowDC(hwnd)
183
+ mfcDC = win32ui.CreateDCFromHandle(hwndDC)
184
+ saveDC = mfcDC.CreateCompatibleDC()
185
+
186
+ saveBitMap = win32ui.CreateBitmap()
187
+ saveBitMap.CreateCompatibleBitmap(mfcDC, w, h)
188
+ saveDC.SelectObject(saveBitMap)
189
+
190
+ # PrintWindow: PW_RENDERFULLCONTENT = 0x00000002
191
+ result = windll.user32.PrintWindow(hwnd, saveDC.GetSafeHdc(), 3)
192
+ if result == 0:
193
+ # Fallback to BitBlt
194
+ saveDC.BitBlt((0, 0), (w, h), mfcDC, (0, 0), win32con.SRCCOPY)
195
+
196
+ bmpinfo = saveBitMap.GetInfo()
197
+ bmpstr = saveBitMap.GetBitmapBits(True)
198
+
199
+ img = np.frombuffer(bmpstr, dtype=np.uint8)
200
+ img.shape = (h, w, 4) # BGRA
201
+ img = img[:, :, :3] # BGR
202
+
203
+ win32gui.DeleteObject(saveBitMap.GetHandle())
204
+ saveDC.DeleteDC()
205
+ mfcDC.DeleteDC()
206
+ win32gui.ReleaseDC(hwnd, hwndDC)
207
+
208
+ if save_raw_path:
209
+ Path(save_raw_path).parent.mkdir(parents=True, exist_ok=True)
210
+ import cv2
211
+ cv2.imwrite(save_raw_path, img)
212
+ logger.debug("Saved raw screenshot to %s", save_raw_path)
213
+
214
+ return img
215
+
216
+ def _capture_fallback(self) -> Optional[np.ndarray]:
217
+ """Non-Windows fallback using mss."""
218
+ try:
219
+ import mss
220
+ import cv2
221
+ except ImportError:
222
+ logger.error("mss and opencv-python required for non-Windows capture.")
223
+ return None
224
+
225
+ sct = mss.mss()
226
+ monitor = self.fallback_monitor or sct.monitors[1]
227
+ img = np.array(sct.grab(monitor))
228
+ img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
229
+ return img
230
+
231
+ def get_info(self) -> Optional[WindowInfo]:
232
+ if self._last_info is None:
233
+ return self.find_window()
234
+ return self._last_info
235
+
236
+
237
+ class MockCapture:
238
+ """Capture from a video file or image sequence for testing."""
239
+
240
+ def __init__(self, source_path: str):
241
+ self.source_path = Path(source_path)
242
+ self._cap = None
243
+ self._frame_idx = 0
244
+ if self.source_path.is_file():
245
+ import cv2
246
+ self._cap = cv2.VideoCapture(str(self.source_path))
247
+
248
+ def capture(self, save_raw_path: Optional[str] = None) -> Optional[np.ndarray]:
249
+ if self._cap is not None:
250
+ ret, frame = self._cap.read()
251
+ self._frame_idx += 1
252
+ if not ret:
253
+ return None
254
+ return frame
255
+ return None
256
+
257
+ def release(self):
258
+ if self._cap is not None:
259
+ self._cap.release()
260
+
261
+
262
+ def list_emulator_windows() -> list:
263
+ """List all windows that look like emulators."""
264
+ cap = EmulatorCapture()
265
+ all_windows = cap._enum_windows()
266
+ emulators = []
267
+ for hwnd, title, class_name in all_windows:
268
+ for sig_class, sig_title in EmulatorCapture.EMULATOR_SIGNATURES:
269
+ if class_name == sig_class or (sig_title and sig_title.lower() in title.lower()):
270
+ emulators.append({"hwnd": hwnd, "title": title, "class_name": class_name})
271
+ break
272
+ return emulators
273
+
274
+
275
+ if __name__ == "__main__":
276
+ logging.basicConfig(level=logging.INFO)
277
+ print("Emulator windows found:")
278
+ for w in list_emulator_windows():
279
+ print(f" hwnd={w['hwnd']} title={w['title']} class={w['class_name']}")