openadapt-ml 0.1.0__py3-none-any.whl → 0.2.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.
Files changed (112) hide show
  1. openadapt_ml/baselines/__init__.py +121 -0
  2. openadapt_ml/baselines/adapter.py +185 -0
  3. openadapt_ml/baselines/cli.py +314 -0
  4. openadapt_ml/baselines/config.py +448 -0
  5. openadapt_ml/baselines/parser.py +922 -0
  6. openadapt_ml/baselines/prompts.py +787 -0
  7. openadapt_ml/benchmarks/__init__.py +13 -107
  8. openadapt_ml/benchmarks/agent.py +297 -374
  9. openadapt_ml/benchmarks/azure.py +62 -24
  10. openadapt_ml/benchmarks/azure_ops_tracker.py +521 -0
  11. openadapt_ml/benchmarks/cli.py +1874 -751
  12. openadapt_ml/benchmarks/trace_export.py +631 -0
  13. openadapt_ml/benchmarks/viewer.py +1236 -0
  14. openadapt_ml/benchmarks/vm_monitor.py +1111 -0
  15. openadapt_ml/benchmarks/waa_deploy/Dockerfile +216 -0
  16. openadapt_ml/benchmarks/waa_deploy/__init__.py +10 -0
  17. openadapt_ml/benchmarks/waa_deploy/api_agent.py +540 -0
  18. openadapt_ml/benchmarks/waa_deploy/start_waa_server.bat +53 -0
  19. openadapt_ml/cloud/azure_inference.py +3 -5
  20. openadapt_ml/cloud/lambda_labs.py +722 -307
  21. openadapt_ml/cloud/local.py +3194 -89
  22. openadapt_ml/cloud/ssh_tunnel.py +595 -0
  23. openadapt_ml/datasets/next_action.py +125 -96
  24. openadapt_ml/evals/grounding.py +32 -9
  25. openadapt_ml/evals/plot_eval_metrics.py +15 -13
  26. openadapt_ml/evals/trajectory_matching.py +120 -57
  27. openadapt_ml/experiments/demo_prompt/__init__.py +19 -0
  28. openadapt_ml/experiments/demo_prompt/format_demo.py +236 -0
  29. openadapt_ml/experiments/demo_prompt/results/experiment_20251231_002125.json +83 -0
  30. openadapt_ml/experiments/demo_prompt/results/experiment_n30_20251231_165958.json +1100 -0
  31. openadapt_ml/experiments/demo_prompt/results/multistep_20251231_025051.json +182 -0
  32. openadapt_ml/experiments/demo_prompt/run_experiment.py +541 -0
  33. openadapt_ml/experiments/representation_shootout/__init__.py +70 -0
  34. openadapt_ml/experiments/representation_shootout/conditions.py +708 -0
  35. openadapt_ml/experiments/representation_shootout/config.py +390 -0
  36. openadapt_ml/experiments/representation_shootout/evaluator.py +659 -0
  37. openadapt_ml/experiments/representation_shootout/runner.py +687 -0
  38. openadapt_ml/experiments/waa_demo/__init__.py +10 -0
  39. openadapt_ml/experiments/waa_demo/demos.py +357 -0
  40. openadapt_ml/experiments/waa_demo/runner.py +732 -0
  41. openadapt_ml/experiments/waa_demo/tasks.py +151 -0
  42. openadapt_ml/export/__init__.py +9 -0
  43. openadapt_ml/export/__main__.py +6 -0
  44. openadapt_ml/export/cli.py +89 -0
  45. openadapt_ml/export/parquet.py +277 -0
  46. openadapt_ml/grounding/detector.py +18 -14
  47. openadapt_ml/ingest/__init__.py +11 -10
  48. openadapt_ml/ingest/capture.py +97 -86
  49. openadapt_ml/ingest/loader.py +120 -69
  50. openadapt_ml/ingest/synthetic.py +344 -193
  51. openadapt_ml/models/api_adapter.py +14 -4
  52. openadapt_ml/models/base_adapter.py +10 -2
  53. openadapt_ml/models/providers/__init__.py +288 -0
  54. openadapt_ml/models/providers/anthropic.py +266 -0
  55. openadapt_ml/models/providers/base.py +299 -0
  56. openadapt_ml/models/providers/google.py +376 -0
  57. openadapt_ml/models/providers/openai.py +342 -0
  58. openadapt_ml/models/qwen_vl.py +46 -19
  59. openadapt_ml/perception/__init__.py +35 -0
  60. openadapt_ml/perception/integration.py +399 -0
  61. openadapt_ml/retrieval/README.md +226 -0
  62. openadapt_ml/retrieval/USAGE.md +391 -0
  63. openadapt_ml/retrieval/__init__.py +91 -0
  64. openadapt_ml/retrieval/demo_retriever.py +843 -0
  65. openadapt_ml/retrieval/embeddings.py +630 -0
  66. openadapt_ml/retrieval/index.py +194 -0
  67. openadapt_ml/retrieval/retriever.py +162 -0
  68. openadapt_ml/runtime/__init__.py +50 -0
  69. openadapt_ml/runtime/policy.py +27 -14
  70. openadapt_ml/runtime/safety_gate.py +471 -0
  71. openadapt_ml/schema/__init__.py +113 -0
  72. openadapt_ml/schema/converters.py +588 -0
  73. openadapt_ml/schema/episode.py +470 -0
  74. openadapt_ml/scripts/capture_screenshots.py +530 -0
  75. openadapt_ml/scripts/compare.py +102 -61
  76. openadapt_ml/scripts/demo_policy.py +4 -1
  77. openadapt_ml/scripts/eval_policy.py +19 -14
  78. openadapt_ml/scripts/make_gif.py +1 -1
  79. openadapt_ml/scripts/prepare_synthetic.py +16 -17
  80. openadapt_ml/scripts/train.py +98 -75
  81. openadapt_ml/segmentation/README.md +920 -0
  82. openadapt_ml/segmentation/__init__.py +97 -0
  83. openadapt_ml/segmentation/adapters/__init__.py +5 -0
  84. openadapt_ml/segmentation/adapters/capture_adapter.py +420 -0
  85. openadapt_ml/segmentation/annotator.py +610 -0
  86. openadapt_ml/segmentation/cache.py +290 -0
  87. openadapt_ml/segmentation/cli.py +674 -0
  88. openadapt_ml/segmentation/deduplicator.py +656 -0
  89. openadapt_ml/segmentation/frame_describer.py +788 -0
  90. openadapt_ml/segmentation/pipeline.py +340 -0
  91. openadapt_ml/segmentation/schemas.py +622 -0
  92. openadapt_ml/segmentation/segment_extractor.py +634 -0
  93. openadapt_ml/training/azure_ops_viewer.py +1097 -0
  94. openadapt_ml/training/benchmark_viewer.py +3255 -19
  95. openadapt_ml/training/shared_ui.py +7 -7
  96. openadapt_ml/training/stub_provider.py +57 -35
  97. openadapt_ml/training/trainer.py +255 -441
  98. openadapt_ml/training/trl_trainer.py +403 -0
  99. openadapt_ml/training/viewer.py +323 -108
  100. openadapt_ml/training/viewer_components.py +180 -0
  101. {openadapt_ml-0.1.0.dist-info → openadapt_ml-0.2.1.dist-info}/METADATA +312 -69
  102. openadapt_ml-0.2.1.dist-info/RECORD +116 -0
  103. openadapt_ml/benchmarks/base.py +0 -366
  104. openadapt_ml/benchmarks/data_collection.py +0 -432
  105. openadapt_ml/benchmarks/runner.py +0 -381
  106. openadapt_ml/benchmarks/waa.py +0 -704
  107. openadapt_ml/schemas/__init__.py +0 -53
  108. openadapt_ml/schemas/sessions.py +0 -122
  109. openadapt_ml/schemas/validation.py +0 -252
  110. openadapt_ml-0.1.0.dist-info/RECORD +0 -55
  111. {openadapt_ml-0.1.0.dist-info → openadapt_ml-0.2.1.dist-info}/WHEEL +0 -0
  112. {openadapt_ml-0.1.0.dist-info → openadapt_ml-0.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,530 @@
1
+ #!/usr/bin/env python3
2
+ """Capture screenshots of dashboards and VMs for documentation.
3
+
4
+ This script captures screenshots from:
5
+ 1. Azure ops dashboard (http://localhost:8765/azure_ops.html)
6
+ 2. VNC viewer (http://localhost:8006) - Windows VM
7
+ 3. Terminal output from VM monitor (uses PIL rendering)
8
+ 4. Training dashboard (http://localhost:8080/dashboard.html)
9
+
10
+ Prerequisites:
11
+ - PIL (Pillow) - for terminal screenshots and image manipulation
12
+ - Optional: playwright - for web page screenshots (better quality)
13
+ - Optional: macOS screencapture - fallback for web pages
14
+
15
+ Usage:
16
+ # Capture all available dashboards
17
+ uv run python -m openadapt_ml.scripts.capture_screenshots
18
+
19
+ # Capture specific targets
20
+ uv run python -m openadapt_ml.scripts.capture_screenshots --target azure-ops
21
+ uv run python -m openadapt_ml.scripts.capture_screenshots --target vnc
22
+ uv run python -m openadapt_ml.scripts.capture_screenshots --target terminal
23
+ uv run python -m openadapt_ml.scripts.capture_screenshots --target training
24
+
25
+ # Capture with custom output directory
26
+ uv run python -m openadapt_ml.scripts.capture_screenshots --output /path/to/screenshots
27
+
28
+ # List available targets
29
+ uv run python -m openadapt_ml.scripts.capture_screenshots --list
30
+
31
+ Output:
32
+ docs/screenshots/{target}_{timestamp}.png
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import datetime
39
+ import re
40
+ import subprocess
41
+ import sys
42
+ from pathlib import Path
43
+
44
+ # Project paths
45
+ SCRIPT_DIR = Path(__file__).parent
46
+ PROJECT_ROOT = SCRIPT_DIR.parent.parent
47
+ DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "docs" / "screenshots"
48
+
49
+
50
+ def get_timestamp() -> str:
51
+ """Get current timestamp string for filenames."""
52
+ return datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
53
+
54
+
55
+ def check_url_available(url: str, timeout: int = 5) -> bool:
56
+ """Check if a URL is accessible."""
57
+ import urllib.request
58
+ import urllib.error
59
+
60
+ try:
61
+ urllib.request.urlopen(url, timeout=timeout)
62
+ return True
63
+ except (urllib.error.URLError, urllib.error.HTTPError):
64
+ return False
65
+
66
+
67
+ def capture_web_page_playwright(url: str, output_path: Path) -> bool:
68
+ """Capture web page screenshot using playwright.
69
+
70
+ Args:
71
+ url: URL to capture
72
+ output_path: Path to save screenshot
73
+
74
+ Returns:
75
+ True if successful, False otherwise
76
+ """
77
+ try:
78
+ from playwright.sync_api import sync_playwright
79
+
80
+ with sync_playwright() as p:
81
+ browser = p.chromium.launch()
82
+ page = browser.new_page(viewport={"width": 1280, "height": 900})
83
+ page.goto(url, wait_until="networkidle")
84
+ # Wait a bit for any dynamic content
85
+ page.wait_for_timeout(2000)
86
+ page.screenshot(path=str(output_path), full_page=False)
87
+ browser.close()
88
+ return True
89
+ except ImportError:
90
+ return False
91
+ except Exception as e:
92
+ print(f" Playwright error: {e}")
93
+ return False
94
+
95
+
96
+ def capture_web_page_selenium(url: str, output_path: Path) -> bool:
97
+ """Capture web page screenshot using selenium.
98
+
99
+ Args:
100
+ url: URL to capture
101
+ output_path: Path to save screenshot
102
+
103
+ Returns:
104
+ True if successful, False otherwise
105
+ """
106
+ try:
107
+ from selenium import webdriver
108
+ from selenium.webdriver.chrome.options import Options
109
+
110
+ options = Options()
111
+ options.add_argument("--headless")
112
+ options.add_argument("--window-size=1280,900")
113
+ options.add_argument("--disable-gpu")
114
+ options.add_argument("--no-sandbox")
115
+
116
+ driver = webdriver.Chrome(options=options)
117
+ driver.get(url)
118
+ import time
119
+
120
+ time.sleep(2) # Wait for dynamic content
121
+ driver.save_screenshot(str(output_path))
122
+ driver.quit()
123
+ return True
124
+ except ImportError:
125
+ return False
126
+ except Exception as e:
127
+ print(f" Selenium error: {e}")
128
+ return False
129
+
130
+
131
+ def capture_web_page_macos(url: str, output_path: Path) -> bool:
132
+ """Capture web page by opening in browser and using macOS screencapture.
133
+
134
+ This is a fallback method that requires manual interaction.
135
+
136
+ Args:
137
+ url: URL to capture
138
+ output_path: Path to save screenshot
139
+
140
+ Returns:
141
+ True if user completed capture, False otherwise
142
+ """
143
+ if sys.platform != "darwin":
144
+ return False
145
+
146
+ print(f" Opening {url} in browser...")
147
+ subprocess.run(["open", url], check=True)
148
+
149
+ print(" Press Enter when ready to capture (or 'q' to skip)...")
150
+ response = input().strip().lower()
151
+ if response == "q":
152
+ return False
153
+
154
+ # Use screencapture with interactive mode (-i) for user to select window
155
+ print(" Click on the window to capture...")
156
+ result = subprocess.run(
157
+ ["screencapture", "-i", "-W", str(output_path)], capture_output=True
158
+ )
159
+ return result.returncode == 0 and output_path.exists()
160
+
161
+
162
+ def capture_web_page(url: str, output_path: Path, interactive: bool = False) -> bool:
163
+ """Capture web page screenshot using best available method.
164
+
165
+ Args:
166
+ url: URL to capture
167
+ output_path: Path to save screenshot
168
+ interactive: If True, allow interactive capture methods
169
+
170
+ Returns:
171
+ True if successful, False otherwise
172
+ """
173
+ output_path.parent.mkdir(parents=True, exist_ok=True)
174
+
175
+ # Try playwright first (best quality)
176
+ if capture_web_page_playwright(url, output_path):
177
+ return True
178
+
179
+ # Try selenium as fallback
180
+ if capture_web_page_selenium(url, output_path):
181
+ return True
182
+
183
+ # On macOS, offer interactive capture
184
+ if interactive and capture_web_page_macos(url, output_path):
185
+ return True
186
+
187
+ return False
188
+
189
+
190
+ def capture_terminal_output(command: list[str], output_path: Path) -> bool:
191
+ """Capture terminal command output as image using PIL.
192
+
193
+ Args:
194
+ command: Command to run
195
+ output_path: Path to save screenshot
196
+
197
+ Returns:
198
+ True if successful, False otherwise
199
+ """
200
+ from PIL import Image, ImageDraw, ImageFont
201
+
202
+ try:
203
+ result = subprocess.run(
204
+ command,
205
+ capture_output=True,
206
+ text=True,
207
+ cwd=PROJECT_ROOT,
208
+ timeout=60,
209
+ )
210
+ output = result.stdout or result.stderr
211
+ except subprocess.TimeoutExpired:
212
+ output = "ERROR: Command timed out"
213
+ except Exception as e:
214
+ output = f"ERROR: {e}"
215
+
216
+ # Strip ANSI codes
217
+ ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
218
+ output = ansi_escape.sub("", output)
219
+
220
+ # Terminal color scheme
221
+ bg_color = (30, 30, 30)
222
+ text_color = (220, 220, 220)
223
+
224
+ # Load font
225
+ font_size = 14
226
+ try:
227
+ font = ImageFont.truetype("/System/Library/Fonts/Monaco.dfont", font_size)
228
+ except Exception:
229
+ try:
230
+ font = ImageFont.truetype("Courier New", font_size)
231
+ except Exception:
232
+ font = ImageFont.load_default()
233
+
234
+ # Calculate dimensions
235
+ lines = output.split("\n")
236
+ line_height = int(font_size * 1.4)
237
+ char_width = font_size * 0.6
238
+ padding = 20
239
+
240
+ max_line_len = max((len(line) for line in lines), default=80)
241
+ width = int(max_line_len * char_width) + 2 * padding
242
+ height = len(lines) * line_height + 2 * padding
243
+
244
+ # Create image
245
+ img = Image.new("RGB", (width, height), bg_color)
246
+ draw = ImageDraw.Draw(img)
247
+
248
+ # Draw text
249
+ y = padding
250
+ for line in lines:
251
+ draw.text((padding, y), line, fill=text_color, font=font)
252
+ y += line_height
253
+
254
+ # Save
255
+ output_path.parent.mkdir(parents=True, exist_ok=True)
256
+ img.save(output_path)
257
+ return True
258
+
259
+
260
+ def capture_vnc_screenshot(output_path: Path) -> bool:
261
+ """Capture VNC viewer screenshot.
262
+
263
+ The VNC viewer at localhost:8006 is a noVNC HTML5 client.
264
+ We capture it as a web page.
265
+
266
+ Args:
267
+ output_path: Path to save screenshot
268
+
269
+ Returns:
270
+ True if successful, False otherwise
271
+ """
272
+ url = "http://localhost:8006"
273
+ if not check_url_available(url):
274
+ print(f" VNC not available at {url}")
275
+ return False
276
+
277
+ return capture_web_page(url, output_path, interactive=True)
278
+
279
+
280
+ def capture_azure_ops_dashboard(output_path: Path) -> bool:
281
+ """Capture Azure ops dashboard screenshot.
282
+
283
+ Args:
284
+ output_path: Path to save screenshot
285
+
286
+ Returns:
287
+ True if successful, False otherwise
288
+ """
289
+ url = "http://localhost:8765/azure_ops.html"
290
+ if not check_url_available(url):
291
+ print(f" Azure ops dashboard not available at {url}")
292
+ return False
293
+
294
+ return capture_web_page(url, output_path, interactive=True)
295
+
296
+
297
+ def capture_training_dashboard(output_path: Path) -> bool:
298
+ """Capture training dashboard screenshot.
299
+
300
+ Args:
301
+ output_path: Path to save screenshot
302
+
303
+ Returns:
304
+ True if successful, False otherwise
305
+ """
306
+ url = "http://localhost:8080/dashboard.html"
307
+ if not check_url_available(url):
308
+ print(f" Training dashboard not available at {url}")
309
+ return False
310
+
311
+ return capture_web_page(url, output_path, interactive=True)
312
+
313
+
314
+ def capture_vm_monitor(output_path: Path, mock: bool = True) -> bool:
315
+ """Capture VM monitor terminal output.
316
+
317
+ Args:
318
+ output_path: Path to save screenshot
319
+ mock: If True, use --mock flag to avoid Azure costs
320
+
321
+ Returns:
322
+ True if successful, False otherwise
323
+ """
324
+ cmd = ["uv", "run", "python", "-m", "openadapt_ml.benchmarks.cli", "vm", "monitor"]
325
+ if mock:
326
+ cmd.append("--mock")
327
+
328
+ return capture_terminal_output(cmd, output_path)
329
+
330
+
331
+ def capture_vm_screenshot_from_vm(output_path: Path) -> bool:
332
+ """Capture VM screen directly via QEMU monitor.
333
+
334
+ This uses the existing vm screenshot command in the CLI.
335
+
336
+ Args:
337
+ output_path: Path to save screenshot
338
+
339
+ Returns:
340
+ True if successful, False otherwise
341
+ """
342
+ result = subprocess.run(
343
+ [
344
+ "uv",
345
+ "run",
346
+ "python",
347
+ "-m",
348
+ "openadapt_ml.benchmarks.cli",
349
+ "vm",
350
+ "screenshot",
351
+ ],
352
+ capture_output=True,
353
+ text=True,
354
+ cwd=PROJECT_ROOT,
355
+ )
356
+
357
+ if result.returncode != 0:
358
+ print(
359
+ f" VM screenshot failed: {result.stderr[:200] if result.stderr else 'Unknown error'}"
360
+ )
361
+ return False
362
+
363
+ # The CLI saves to training_output/current/vm_screenshot.png
364
+ src_path = PROJECT_ROOT / "training_output" / "current" / "vm_screenshot.png"
365
+ if src_path.exists():
366
+ import shutil
367
+
368
+ output_path.parent.mkdir(parents=True, exist_ok=True)
369
+ shutil.copy(src_path, output_path)
370
+ return True
371
+
372
+ return False
373
+
374
+
375
+ TARGETS = {
376
+ "azure-ops": {
377
+ "description": "Azure ops dashboard (localhost:8765)",
378
+ "capture_fn": capture_azure_ops_dashboard,
379
+ "filename": "azure_ops_dashboard",
380
+ },
381
+ "vnc": {
382
+ "description": "VNC viewer (localhost:8006) - Windows VM",
383
+ "capture_fn": capture_vnc_screenshot,
384
+ "filename": "vnc_viewer",
385
+ },
386
+ "terminal": {
387
+ "description": "VM monitor terminal output",
388
+ "capture_fn": lambda p: capture_vm_monitor(p, mock=True),
389
+ "filename": "vm_monitor_terminal",
390
+ },
391
+ "terminal-live": {
392
+ "description": "VM monitor terminal output (live, no mock)",
393
+ "capture_fn": lambda p: capture_vm_monitor(p, mock=False),
394
+ "filename": "vm_monitor_terminal_live",
395
+ },
396
+ "training": {
397
+ "description": "Training dashboard (localhost:8080)",
398
+ "capture_fn": capture_training_dashboard,
399
+ "filename": "training_dashboard",
400
+ },
401
+ "vm-screen": {
402
+ "description": "Windows VM screen (via QEMU)",
403
+ "capture_fn": capture_vm_screenshot_from_vm,
404
+ "filename": "vm_screen",
405
+ },
406
+ }
407
+
408
+
409
+ def main():
410
+ parser = argparse.ArgumentParser(
411
+ description="Capture screenshots of dashboards and VMs for documentation",
412
+ formatter_class=argparse.RawDescriptionHelpFormatter,
413
+ epilog="""
414
+ Examples:
415
+ # Capture all available targets
416
+ uv run python -m openadapt_ml.scripts.capture_screenshots
417
+
418
+ # Capture specific target
419
+ uv run python -m openadapt_ml.scripts.capture_screenshots --target azure-ops
420
+
421
+ # Capture multiple targets
422
+ uv run python -m openadapt_ml.scripts.capture_screenshots --target azure-ops --target vnc
423
+
424
+ # List available targets
425
+ uv run python -m openadapt_ml.scripts.capture_screenshots --list
426
+ """,
427
+ )
428
+ parser.add_argument(
429
+ "--target",
430
+ "-t",
431
+ action="append",
432
+ choices=list(TARGETS.keys()),
433
+ help="Target to capture (can specify multiple)",
434
+ )
435
+ parser.add_argument(
436
+ "--output",
437
+ "-o",
438
+ type=Path,
439
+ default=DEFAULT_OUTPUT_DIR,
440
+ help="Output directory for screenshots",
441
+ )
442
+ parser.add_argument(
443
+ "--list",
444
+ "-l",
445
+ action="store_true",
446
+ help="List available targets",
447
+ )
448
+ parser.add_argument(
449
+ "--no-timestamp",
450
+ action="store_true",
451
+ help="Don't add timestamp to filenames",
452
+ )
453
+ parser.add_argument(
454
+ "--interactive",
455
+ "-i",
456
+ action="store_true",
457
+ help="Allow interactive capture methods (e.g., macOS screencapture)",
458
+ )
459
+
460
+ args = parser.parse_args()
461
+
462
+ if args.list:
463
+ print("\nAvailable screenshot targets:\n")
464
+ for name, info in TARGETS.items():
465
+ print(f" {name:15} - {info['description']}")
466
+ print()
467
+ return 0
468
+
469
+ # Determine targets to capture
470
+ targets = args.target or list(TARGETS.keys())
471
+
472
+ # Create output directory
473
+ output_dir = args.output
474
+ output_dir.mkdir(parents=True, exist_ok=True)
475
+
476
+ print("=" * 60)
477
+ print(" Screenshot Capture Tool ".center(60))
478
+ print("=" * 60)
479
+ print(f"\nOutput directory: {output_dir}")
480
+ print(f"Targets: {', '.join(targets)}\n")
481
+
482
+ timestamp = get_timestamp() if not args.no_timestamp else ""
483
+ results = {}
484
+
485
+ for target in targets:
486
+ info = TARGETS[target]
487
+ print(f"\n[{target}] {info['description']}")
488
+
489
+ filename = info["filename"]
490
+ if timestamp:
491
+ filename = f"{filename}_{timestamp}"
492
+ output_path = output_dir / f"{filename}.png"
493
+
494
+ try:
495
+ success = info["capture_fn"](output_path)
496
+ if success:
497
+ size_kb = output_path.stat().st_size / 1024
498
+ print(f" OK: {output_path.name} ({size_kb:.1f} KB)")
499
+ results[target] = str(output_path)
500
+ else:
501
+ print(" SKIP: Not available or capture failed")
502
+ results[target] = None
503
+ except Exception as e:
504
+ print(f" ERROR: {e}")
505
+ results[target] = None
506
+
507
+ # Summary
508
+ print("\n" + "=" * 60)
509
+ print(" Summary ".center(60))
510
+ print("=" * 60)
511
+
512
+ successful = [t for t, p in results.items() if p]
513
+ failed = [t for t, p in results.items() if not p]
514
+
515
+ if successful:
516
+ print(f"\nCaptured ({len(successful)}):")
517
+ for target in successful:
518
+ print(f" - {results[target]}")
519
+
520
+ if failed:
521
+ print(f"\nSkipped/Failed ({len(failed)}):")
522
+ for target in failed:
523
+ print(f" - {target}")
524
+
525
+ print()
526
+ return 0 if successful else 1
527
+
528
+
529
+ if __name__ == "__main__":
530
+ sys.exit(main())