interpreter-v2 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.
- interpreter/__init__.py +13 -0
- interpreter/__main__.py +450 -0
- interpreter/capture.py +155 -0
- interpreter/capture_macos.py +295 -0
- interpreter/capture_windows.py +366 -0
- interpreter/config.py +72 -0
- interpreter/gpu.py +62 -0
- interpreter/models.py +39 -0
- interpreter/ocr.py +453 -0
- interpreter/overlay.py +483 -0
- interpreter/translate.py +169 -0
- interpreter_v2-0.1.0.dist-info/METADATA +149 -0
- interpreter_v2-0.1.0.dist-info/RECORD +15 -0
- interpreter_v2-0.1.0.dist-info/WHEEL +4 -0
- interpreter_v2-0.1.0.dist-info/entry_points.txt +2 -0
interpreter/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Interpreter - Offline screen translator for Japanese retro games.
|
|
2
|
+
|
|
3
|
+
This application captures text from a target window, performs OCR using
|
|
4
|
+
MeikiOCR, translates using Sugoi V4, and displays subtitles in a
|
|
5
|
+
transparent overlay.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
# Public API
|
|
11
|
+
from .__main__ import main, list_windows
|
|
12
|
+
|
|
13
|
+
__all__ = ["main", "list_windows", "__version__"]
|
interpreter/__main__.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""Main entry point for Interpreter.
|
|
2
|
+
|
|
3
|
+
This module is executed when running:
|
|
4
|
+
- python -m interpreter
|
|
5
|
+
- interpreter (via pyproject.toml entry point)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
# Setup CUDA DLLs early (before any CUDA-dependent imports)
|
|
12
|
+
# This must happen before importing ctranslate2 or onnxruntime
|
|
13
|
+
if sys.platform == "win32":
|
|
14
|
+
from .gpu import setup_cuda_dlls
|
|
15
|
+
setup_cuda_dlls()
|
|
16
|
+
|
|
17
|
+
# Suppress harmless onnxruntime semaphore warning on exit
|
|
18
|
+
# Must be set before multiprocessing is imported
|
|
19
|
+
os.environ["PYTHONWARNINGS"] = "ignore::UserWarning:multiprocessing.resource_tracker"
|
|
20
|
+
|
|
21
|
+
# Suppress HuggingFace token warning (public models don't need auth)
|
|
22
|
+
os.environ["HF_HUB_DISABLE_IMPLICIT_TOKEN"] = "1"
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import time
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
from pynput import keyboard
|
|
29
|
+
|
|
30
|
+
from .capture import WindowCapture
|
|
31
|
+
from .config import Config
|
|
32
|
+
from .ocr import OCR
|
|
33
|
+
from .overlay import Overlay
|
|
34
|
+
from .translate import Translator, text_similarity
|
|
35
|
+
|
|
36
|
+
# Main loop timing constants
|
|
37
|
+
TEXT_SIMILARITY_THRESHOLD = 0.9 # Skip OCR if 90%+ similar to previous
|
|
38
|
+
UI_POLL_INTERVAL = 0.05 # Sleep between UI updates (seconds)
|
|
39
|
+
PAUSED_POLL_INTERVAL = 0.1 # Sleep when overlay paused (seconds)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def list_windows():
|
|
43
|
+
"""List all available windows and exit."""
|
|
44
|
+
print("Available windows:")
|
|
45
|
+
print("-" * 60)
|
|
46
|
+
|
|
47
|
+
windows = WindowCapture.list_windows()
|
|
48
|
+
for w in windows:
|
|
49
|
+
title = w["title"][:50] + "..." if len(w["title"]) > 50 else w["title"]
|
|
50
|
+
print(f" {title}")
|
|
51
|
+
|
|
52
|
+
print("-" * 60)
|
|
53
|
+
print(f"Total: {len(windows)} windows")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_arguments() -> argparse.Namespace:
|
|
57
|
+
"""Parse command-line arguments."""
|
|
58
|
+
parser = argparse.ArgumentParser(
|
|
59
|
+
description="Offline screen translator for Japanese games"
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--config", "-c",
|
|
63
|
+
type=str,
|
|
64
|
+
default=None,
|
|
65
|
+
help="Path to config file (default: config.yml)"
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--window", "-w",
|
|
69
|
+
type=str,
|
|
70
|
+
default=None,
|
|
71
|
+
help="Window title to capture (overrides config)"
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--list-windows", "-l",
|
|
75
|
+
action="store_true",
|
|
76
|
+
help="List available windows and exit"
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--no-translate",
|
|
80
|
+
action="store_true",
|
|
81
|
+
help="Skip translation (OCR only, for testing)"
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--debug",
|
|
85
|
+
action="store_true",
|
|
86
|
+
help="Show per-character OCR confidence scores"
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--overlay-mode", "-m",
|
|
90
|
+
type=str,
|
|
91
|
+
choices=["banner", "inplace"],
|
|
92
|
+
default=None,
|
|
93
|
+
help="Overlay mode: banner (subtitle at bottom) or inplace (over game text)"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return parser.parse_args()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _initialize_components(
|
|
100
|
+
config: Config, args: argparse.Namespace
|
|
101
|
+
) -> tuple[WindowCapture, OCR, Optional[Translator], Overlay]:
|
|
102
|
+
"""Initialize capture, OCR, translator, and overlay.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
config: Application configuration.
|
|
106
|
+
args: Parsed command-line arguments.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Tuple of (capture, ocr, translator, overlay).
|
|
110
|
+
"""
|
|
111
|
+
print("Initializing components...")
|
|
112
|
+
|
|
113
|
+
# Initialize screen capture
|
|
114
|
+
capture = WindowCapture(config.window_title)
|
|
115
|
+
if not capture.find_window():
|
|
116
|
+
print(f"Error: Window '{config.window_title}' not found.")
|
|
117
|
+
print("Use --list-windows to see available windows.")
|
|
118
|
+
sys.exit(1)
|
|
119
|
+
print(f" Window found: {config.window_title}")
|
|
120
|
+
|
|
121
|
+
# Initialize OCR
|
|
122
|
+
ocr = OCR(confidence_threshold=config.ocr_confidence, debug=args.debug)
|
|
123
|
+
print(" OCR: MeikiOCR (will load on first use)")
|
|
124
|
+
|
|
125
|
+
# Initialize translator (lazy loading)
|
|
126
|
+
translator = None
|
|
127
|
+
if not args.no_translate:
|
|
128
|
+
translator = Translator()
|
|
129
|
+
print(" Translator: Sugoi V4 (will load on first use)")
|
|
130
|
+
else:
|
|
131
|
+
print(" Translator: DISABLED (--no-translate)")
|
|
132
|
+
|
|
133
|
+
print(f" Window bounds: {capture.bounds}")
|
|
134
|
+
|
|
135
|
+
# Start capture stream
|
|
136
|
+
if not capture.start_stream():
|
|
137
|
+
print("Error: Could not start capture stream.")
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
print(" Capture stream started")
|
|
140
|
+
|
|
141
|
+
# Wait for first frame for Retina scale detection
|
|
142
|
+
initial_image = None
|
|
143
|
+
for _ in range(50): # Wait up to ~2.5 seconds
|
|
144
|
+
initial_image = capture.get_frame()
|
|
145
|
+
if initial_image is not None:
|
|
146
|
+
break
|
|
147
|
+
time.sleep(0.05)
|
|
148
|
+
|
|
149
|
+
if initial_image is None:
|
|
150
|
+
print("Error: Could not capture initial image for overlay setup.")
|
|
151
|
+
capture.stop_stream()
|
|
152
|
+
sys.exit(1)
|
|
153
|
+
image_size = (initial_image.width, initial_image.height)
|
|
154
|
+
|
|
155
|
+
# Debug: save initial capture to verify what we're capturing
|
|
156
|
+
debug_path = "debug_capture.png"
|
|
157
|
+
initial_image.save(debug_path)
|
|
158
|
+
print(f" Debug: saved capture to {debug_path}")
|
|
159
|
+
|
|
160
|
+
# Create unified overlay
|
|
161
|
+
display_bounds = capture.get_display_bounds()
|
|
162
|
+
print(f" Display bounds: {display_bounds}")
|
|
163
|
+
|
|
164
|
+
overlay = Overlay(
|
|
165
|
+
font_size=config.font_size,
|
|
166
|
+
font_color=config.font_color,
|
|
167
|
+
background_color=config.background_color,
|
|
168
|
+
)
|
|
169
|
+
overlay.create(
|
|
170
|
+
display_bounds=display_bounds,
|
|
171
|
+
window_bounds=capture.bounds,
|
|
172
|
+
image_size=image_size,
|
|
173
|
+
mode=config.overlay_mode,
|
|
174
|
+
)
|
|
175
|
+
print(f" Overlay mode: {config.overlay_mode}")
|
|
176
|
+
|
|
177
|
+
return capture, ocr, translator, overlay
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _create_hotkey_handler() -> tuple[dict, callable]:
|
|
181
|
+
"""Create hotkey state and handler function.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Tuple of (state_dict, handler_function).
|
|
185
|
+
state_dict contains flags that are set when hotkeys are pressed.
|
|
186
|
+
"""
|
|
187
|
+
state = {
|
|
188
|
+
"cycle_mode": False,
|
|
189
|
+
"increase_font": False,
|
|
190
|
+
"decrease_font": False,
|
|
191
|
+
"quit": False,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
def on_key_press(key):
|
|
195
|
+
try:
|
|
196
|
+
if hasattr(key, 'char'):
|
|
197
|
+
if key.char == 'm':
|
|
198
|
+
state["cycle_mode"] = True
|
|
199
|
+
elif key.char == '=':
|
|
200
|
+
state["increase_font"] = True
|
|
201
|
+
elif key.char == '-':
|
|
202
|
+
state["decrease_font"] = True
|
|
203
|
+
elif key.char == 'q':
|
|
204
|
+
state["quit"] = True
|
|
205
|
+
except AttributeError:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
return state, on_key_press
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _run_main_loop(
|
|
212
|
+
overlay: Overlay,
|
|
213
|
+
capture: WindowCapture,
|
|
214
|
+
ocr: OCR,
|
|
215
|
+
translator: Optional[Translator],
|
|
216
|
+
config: Config,
|
|
217
|
+
hotkey_state: dict,
|
|
218
|
+
debug_mode: bool,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Run the main translation loop.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
overlay: The overlay window.
|
|
224
|
+
capture: Window capture instance.
|
|
225
|
+
ocr: OCR instance.
|
|
226
|
+
translator: Translator instance (may be None).
|
|
227
|
+
config: Application configuration.
|
|
228
|
+
hotkey_state: Dict with hotkey flags.
|
|
229
|
+
debug_mode: Whether to print debug info.
|
|
230
|
+
"""
|
|
231
|
+
# Track previous text to avoid re-translating
|
|
232
|
+
previous_text = ""
|
|
233
|
+
last_capture_time = 0
|
|
234
|
+
|
|
235
|
+
while overlay.is_running:
|
|
236
|
+
# Process UI events
|
|
237
|
+
overlay.update()
|
|
238
|
+
|
|
239
|
+
# Handle global hotkeys
|
|
240
|
+
if hotkey_state["cycle_mode"]:
|
|
241
|
+
hotkey_state["cycle_mode"] = False
|
|
242
|
+
# Cycle: off → banner → inplace → off
|
|
243
|
+
if overlay.paused:
|
|
244
|
+
overlay.set_mode("banner")
|
|
245
|
+
overlay.resume()
|
|
246
|
+
print("[MODE] Banner")
|
|
247
|
+
elif overlay.mode == "banner":
|
|
248
|
+
overlay.set_mode("inplace")
|
|
249
|
+
print("[MODE] Inplace")
|
|
250
|
+
else:
|
|
251
|
+
overlay.pause()
|
|
252
|
+
print("[MODE] Off")
|
|
253
|
+
|
|
254
|
+
if hotkey_state["increase_font"]:
|
|
255
|
+
hotkey_state["increase_font"] = False
|
|
256
|
+
overlay.adjust_font_size(2)
|
|
257
|
+
print(f"[FONT] Size: {overlay.font_size}")
|
|
258
|
+
|
|
259
|
+
if hotkey_state["decrease_font"]:
|
|
260
|
+
hotkey_state["decrease_font"] = False
|
|
261
|
+
overlay.adjust_font_size(-2)
|
|
262
|
+
print(f"[FONT] Size: {overlay.font_size}")
|
|
263
|
+
|
|
264
|
+
if hotkey_state["quit"]:
|
|
265
|
+
print("\n[QUIT] Exiting...")
|
|
266
|
+
break
|
|
267
|
+
|
|
268
|
+
# Skip processing if overlay is paused
|
|
269
|
+
if overlay.paused:
|
|
270
|
+
time.sleep(PAUSED_POLL_INTERVAL)
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
# Check if it's time for a new capture
|
|
274
|
+
current_time = time.time()
|
|
275
|
+
if current_time - last_capture_time < config.refresh_rate:
|
|
276
|
+
time.sleep(UI_POLL_INTERVAL)
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
last_capture_time = current_time
|
|
280
|
+
loop_start = time.perf_counter()
|
|
281
|
+
|
|
282
|
+
# Get latest frame from stream
|
|
283
|
+
capture_start = time.perf_counter()
|
|
284
|
+
image = capture.get_frame()
|
|
285
|
+
capture_time = time.perf_counter() - capture_start
|
|
286
|
+
|
|
287
|
+
if image is None:
|
|
288
|
+
if overlay.mode == "inplace":
|
|
289
|
+
overlay.update_regions([])
|
|
290
|
+
else:
|
|
291
|
+
overlay.update_text("[Window not found]")
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
# Update overlay position if game window moved/resized
|
|
295
|
+
overlay.update_position(
|
|
296
|
+
capture.bounds,
|
|
297
|
+
display_bounds=capture.get_display_bounds(),
|
|
298
|
+
image_size=(image.width, image.height)
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Extract text
|
|
302
|
+
ocr_start = time.perf_counter()
|
|
303
|
+
try:
|
|
304
|
+
if overlay.mode == "inplace":
|
|
305
|
+
regions = ocr.extract_text_regions(image)
|
|
306
|
+
text = " ".join(r.text for r in regions if r.text)
|
|
307
|
+
else:
|
|
308
|
+
text = ocr.extract_text(image)
|
|
309
|
+
regions = []
|
|
310
|
+
except Exception as e:
|
|
311
|
+
print(f"OCR error: {e}")
|
|
312
|
+
continue
|
|
313
|
+
ocr_time = time.perf_counter() - ocr_start
|
|
314
|
+
|
|
315
|
+
# Skip if text hasn't changed (banner mode only - inplace always updates)
|
|
316
|
+
if overlay.mode != "inplace":
|
|
317
|
+
similarity = text_similarity(text, previous_text)
|
|
318
|
+
if similarity >= TEXT_SIMILARITY_THRESHOLD:
|
|
319
|
+
if debug_mode:
|
|
320
|
+
total_time = time.perf_counter() - loop_start
|
|
321
|
+
print(f"[TIMING] capture: {capture_time*1000:.0f}ms | ocr: {ocr_time*1000:.0f}ms | (similar: {similarity:.0%}) | total: {total_time*1000:.0f}ms")
|
|
322
|
+
continue
|
|
323
|
+
previous_text = text
|
|
324
|
+
|
|
325
|
+
if not text:
|
|
326
|
+
if overlay.mode == "inplace":
|
|
327
|
+
overlay.update_regions([])
|
|
328
|
+
else:
|
|
329
|
+
overlay.update_text("")
|
|
330
|
+
if debug_mode:
|
|
331
|
+
total_time = time.perf_counter() - loop_start
|
|
332
|
+
print(f"[TIMING] capture: {capture_time*1000:.0f}ms | ocr: {ocr_time*1000:.0f}ms | (no text) | total: {total_time*1000:.0f}ms")
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
if overlay.mode == "inplace" and debug_mode:
|
|
336
|
+
print(f"[DBG] Found {len(regions)} regions")
|
|
337
|
+
|
|
338
|
+
# Translate
|
|
339
|
+
translate_time = 0.0
|
|
340
|
+
was_cached = False
|
|
341
|
+
if overlay.mode == "inplace":
|
|
342
|
+
# Translate each region separately
|
|
343
|
+
translated_regions = []
|
|
344
|
+
translate_start = time.perf_counter()
|
|
345
|
+
for region in regions:
|
|
346
|
+
if translator:
|
|
347
|
+
try:
|
|
348
|
+
translated, cached = translator.translate(region.text)
|
|
349
|
+
was_cached = was_cached or cached
|
|
350
|
+
except Exception as e:
|
|
351
|
+
print(f"Translation error: {e}")
|
|
352
|
+
translated = region.text
|
|
353
|
+
else:
|
|
354
|
+
translated = region.text
|
|
355
|
+
translated_regions.append((translated, region.bbox))
|
|
356
|
+
translate_time = time.perf_counter() - translate_start
|
|
357
|
+
|
|
358
|
+
# Update overlay with all regions
|
|
359
|
+
overlay.update_regions(translated_regions)
|
|
360
|
+
|
|
361
|
+
# Print each region
|
|
362
|
+
for region, (translated, _) in zip(regions, translated_regions):
|
|
363
|
+
print(f"[OCR] {region.text}")
|
|
364
|
+
if translator:
|
|
365
|
+
print(f"[EN] {translated}")
|
|
366
|
+
else:
|
|
367
|
+
# Banner mode: single text block
|
|
368
|
+
if translator:
|
|
369
|
+
translate_start = time.perf_counter()
|
|
370
|
+
try:
|
|
371
|
+
translated, was_cached = translator.translate(text)
|
|
372
|
+
display_text = translated
|
|
373
|
+
except Exception as e:
|
|
374
|
+
print(f"Translation error: {e}")
|
|
375
|
+
display_text = f"[{text}]"
|
|
376
|
+
translate_time = time.perf_counter() - translate_start
|
|
377
|
+
else:
|
|
378
|
+
display_text = text
|
|
379
|
+
|
|
380
|
+
overlay.update_text(display_text)
|
|
381
|
+
print(f"[OCR] {text}")
|
|
382
|
+
if translator:
|
|
383
|
+
cache_indicator = " (cached)" if was_cached else ""
|
|
384
|
+
print(f"[EN] {display_text}{cache_indicator}")
|
|
385
|
+
|
|
386
|
+
# Print timing in debug mode
|
|
387
|
+
if debug_mode:
|
|
388
|
+
total_time = time.perf_counter() - loop_start
|
|
389
|
+
cache_str = " CACHE" if was_cached else ""
|
|
390
|
+
if translator:
|
|
391
|
+
print(f"[TIMING] capture: {capture_time*1000:.0f}ms | ocr: {ocr_time*1000:.0f}ms | translate: {translate_time*1000:.0f}ms{cache_str} | total: {total_time*1000:.0f}ms")
|
|
392
|
+
else:
|
|
393
|
+
print(f"[TIMING] capture: {capture_time*1000:.0f}ms | ocr: {ocr_time*1000:.0f}ms | total: {total_time*1000:.0f}ms")
|
|
394
|
+
print()
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def main():
|
|
398
|
+
"""Main entry point."""
|
|
399
|
+
args = _parse_arguments()
|
|
400
|
+
|
|
401
|
+
# List windows mode
|
|
402
|
+
if args.list_windows:
|
|
403
|
+
list_windows()
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
# Load configuration
|
|
407
|
+
config = Config.load(args.config)
|
|
408
|
+
|
|
409
|
+
# Override with CLI arguments
|
|
410
|
+
if args.window:
|
|
411
|
+
config.window_title = args.window
|
|
412
|
+
if args.overlay_mode:
|
|
413
|
+
config.overlay_mode = args.overlay_mode
|
|
414
|
+
|
|
415
|
+
print("Interpreter - Offline Screen Translator")
|
|
416
|
+
print("=" * 50)
|
|
417
|
+
print(f"Target window: {config.window_title}")
|
|
418
|
+
print(f"Refresh rate: {config.refresh_rate}s")
|
|
419
|
+
print(f"Overlay mode: {config.overlay_mode}")
|
|
420
|
+
print()
|
|
421
|
+
|
|
422
|
+
# Initialize components
|
|
423
|
+
capture, ocr, translator, overlay = _initialize_components(config, args)
|
|
424
|
+
|
|
425
|
+
# Setup hotkeys
|
|
426
|
+
hotkey_state, on_key_press = _create_hotkey_handler()
|
|
427
|
+
keyboard_listener = keyboard.Listener(on_press=on_key_press)
|
|
428
|
+
keyboard_listener.start()
|
|
429
|
+
|
|
430
|
+
print()
|
|
431
|
+
print("Starting translation loop...")
|
|
432
|
+
print("Press 'm' to cycle mode (off → banner → inplace), '-/=' to adjust font, 'q' to quit")
|
|
433
|
+
print("-" * 50)
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
_run_main_loop(
|
|
437
|
+
overlay, capture, ocr, translator,
|
|
438
|
+
config, hotkey_state, args.debug
|
|
439
|
+
)
|
|
440
|
+
except KeyboardInterrupt:
|
|
441
|
+
print("\nInterrupted by user")
|
|
442
|
+
finally:
|
|
443
|
+
keyboard_listener.stop()
|
|
444
|
+
capture.stop_stream()
|
|
445
|
+
overlay.quit()
|
|
446
|
+
print("Goodbye!")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
if __name__ == "__main__":
|
|
450
|
+
main()
|
interpreter/capture.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Platform-agnostic screen capture interface."""
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
# Import platform-specific implementation
|
|
9
|
+
_system = platform.system()
|
|
10
|
+
|
|
11
|
+
if _system == "Darwin":
|
|
12
|
+
from .capture_macos import find_window_by_title, capture_window, get_window_list, get_display_bounds_for_window, _get_window_bounds, MacOSCaptureStream
|
|
13
|
+
CaptureStream = MacOSCaptureStream
|
|
14
|
+
elif _system == "Windows":
|
|
15
|
+
from .capture_windows import find_window_by_title, capture_window, get_window_list, _get_window_bounds, WindowsCaptureStream
|
|
16
|
+
CaptureStream = WindowsCaptureStream
|
|
17
|
+
# Windows doesn't have display bounds detection yet
|
|
18
|
+
def get_display_bounds_for_window(window_id: int) -> Optional[dict]:
|
|
19
|
+
return None
|
|
20
|
+
else:
|
|
21
|
+
raise RuntimeError(f"Unsupported platform: {_system}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WindowCapture:
|
|
25
|
+
"""Captures screenshots of a specific window by title."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, window_title: str):
|
|
28
|
+
"""Initialize the window capture.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
window_title: Partial title of the window to capture.
|
|
32
|
+
"""
|
|
33
|
+
self.window_title = window_title
|
|
34
|
+
self._window_id: Optional[int] = None
|
|
35
|
+
self._last_bounds: Optional[dict] = None
|
|
36
|
+
self._stream: Optional[CaptureStream] = None
|
|
37
|
+
|
|
38
|
+
def find_window(self) -> bool:
|
|
39
|
+
"""Find and cache the window ID.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
True if window was found, False otherwise.
|
|
43
|
+
"""
|
|
44
|
+
window = find_window_by_title(self.window_title)
|
|
45
|
+
if window:
|
|
46
|
+
self._window_id = window["id"]
|
|
47
|
+
self._last_bounds = window["bounds"]
|
|
48
|
+
return True
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
def capture(self) -> Optional[Image.Image]:
|
|
52
|
+
"""Capture a screenshot of the target window.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
PIL Image of the window, or None if capture failed.
|
|
56
|
+
"""
|
|
57
|
+
# Try to find window if we don't have an ID yet
|
|
58
|
+
if self._window_id is None:
|
|
59
|
+
if not self.find_window():
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
# Capture the window
|
|
63
|
+
image = capture_window(self._window_id)
|
|
64
|
+
|
|
65
|
+
# If capture failed, the window might have been closed
|
|
66
|
+
# Try to find it again
|
|
67
|
+
if image is None:
|
|
68
|
+
self._window_id = None
|
|
69
|
+
if self.find_window():
|
|
70
|
+
image = capture_window(self._window_id)
|
|
71
|
+
|
|
72
|
+
# Update bounds (window may have moved)
|
|
73
|
+
if image is not None and self._window_id is not None:
|
|
74
|
+
self._refresh_bounds()
|
|
75
|
+
|
|
76
|
+
return image
|
|
77
|
+
|
|
78
|
+
def _refresh_bounds(self) -> None:
|
|
79
|
+
"""Refresh the cached window bounds."""
|
|
80
|
+
if self._window_id is None:
|
|
81
|
+
return
|
|
82
|
+
# Get updated bounds directly by window ID (more efficient)
|
|
83
|
+
bounds = _get_window_bounds(self._window_id)
|
|
84
|
+
if bounds:
|
|
85
|
+
self._last_bounds = bounds
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def window_found(self) -> bool:
|
|
89
|
+
"""Check if the target window has been found."""
|
|
90
|
+
return self._window_id is not None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def bounds(self) -> Optional[dict]:
|
|
94
|
+
"""Get the bounds of the target window."""
|
|
95
|
+
return self._last_bounds
|
|
96
|
+
|
|
97
|
+
def get_display_bounds(self) -> Optional[dict]:
|
|
98
|
+
"""Get the bounds of the display containing the target window."""
|
|
99
|
+
if self._window_id is None:
|
|
100
|
+
return None
|
|
101
|
+
return get_display_bounds_for_window(self._window_id)
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def list_windows() -> list[dict]:
|
|
105
|
+
"""List all available windows.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of window dictionaries with id, title, and bounds.
|
|
109
|
+
"""
|
|
110
|
+
return get_window_list()
|
|
111
|
+
|
|
112
|
+
def start_stream(self) -> bool:
|
|
113
|
+
"""Start the background capture stream.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
True if stream started successfully, False otherwise.
|
|
117
|
+
"""
|
|
118
|
+
# Find window first if needed
|
|
119
|
+
if self._window_id is None:
|
|
120
|
+
if not self.find_window():
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
# Create platform-specific stream
|
|
124
|
+
if _system == "Windows":
|
|
125
|
+
# Windows uses window title for capture
|
|
126
|
+
self._stream = CaptureStream(self.window_title)
|
|
127
|
+
else:
|
|
128
|
+
# macOS uses window ID for capture
|
|
129
|
+
self._stream = CaptureStream(self._window_id)
|
|
130
|
+
|
|
131
|
+
self._stream.start()
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
def get_frame(self) -> Optional[Image.Image]:
|
|
135
|
+
"""Get the latest frame from the capture stream.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
PIL Image of the window, or None if no frame available.
|
|
139
|
+
"""
|
|
140
|
+
if self._stream is None:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
frame = self._stream.get_frame()
|
|
144
|
+
|
|
145
|
+
# Update bounds if we got a frame
|
|
146
|
+
if frame is not None and self._window_id is not None:
|
|
147
|
+
self._refresh_bounds()
|
|
148
|
+
|
|
149
|
+
return frame
|
|
150
|
+
|
|
151
|
+
def stop_stream(self):
|
|
152
|
+
"""Stop the background capture stream."""
|
|
153
|
+
if self._stream is not None:
|
|
154
|
+
self._stream.stop()
|
|
155
|
+
self._stream = None
|