@mengruo/dsh-vision-toolkit 0.0.1

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 (216) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +356 -0
  4. package/README.zh.md +354 -0
  5. package/assets/community-group-qr.png +0 -0
  6. package/assets/dsh-conversation-artifact.png +0 -0
  7. package/assets/dsh-conversation-image-qa-top.png +0 -0
  8. package/assets/dsh-conversation-image-qa.png +0 -0
  9. package/assets/dsh-conversation-pixel-diff.png +0 -0
  10. package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
  11. package/assets/dsh-conversation-screenshot-debugging.png +0 -0
  12. package/assets/dsh-conversation-tool-call.png +0 -0
  13. package/assets/dsh-conversation-vision-trace.png +0 -0
  14. package/assets/dsh-view-example.png +0 -0
  15. package/assets/hero-v2.png +0 -0
  16. package/assets/logo_aihubmix.png +0 -0
  17. package/assets/python-bootstrap.json +48 -0
  18. package/assets/skill/SKILL.md +329 -0
  19. package/assets/skill/UPSTREAM.json +71 -0
  20. package/assets/skill/references/gui.md +88 -0
  21. package/assets/skill/references/long-screenshot-ocr.md +77 -0
  22. package/assets/skill/references/restore-graphic.md +84 -0
  23. package/assets/skill/references/restore-structure.md +45 -0
  24. package/assets/skill/references/restore-ui.md +202 -0
  25. package/assets/social-preview.png +0 -0
  26. package/assets/upstream/README.md +18 -0
  27. package/assets/upstream/focus-hint-comparison-1.webp +0 -0
  28. package/assets/upstream/focus-hint-comparison-2.webp +0 -0
  29. package/assets/upstream/infographic-reference.webp +0 -0
  30. package/assets/upstream/infographic-result.webp +0 -0
  31. package/assets/upstream/ui-fast-restore-reference.webp +0 -0
  32. package/assets/upstream/ui-fast-restore-result.webp +0 -0
  33. package/assets/upstream/ui-result.webp +0 -0
  34. package/assets/upstream/ui-sketch.webp +0 -0
  35. package/assets/vision-model-test.png +0 -0
  36. package/assets/vision-settings.png +0 -0
  37. package/assets/wechat-reward.png +0 -0
  38. package/cordis.patch.yml +6 -0
  39. package/docs/aihubmix-gemini-vision.i18n.yaml +6 -0
  40. package/docs/aihubmix-gemini-vision.md +138 -0
  41. package/docs/aihubmix-gemini-vision.zh.md +138 -0
  42. package/docs/assets/aihubmix-api-keys.png +0 -0
  43. package/docs/assets/aihubmix-create-key.png +0 -0
  44. package/docs/assets/aihubmix-free-vision-model.png +0 -0
  45. package/docs/assets/aihubmix-home.png +0 -0
  46. package/docs/assets/aihubmix-sign-up.png +0 -0
  47. package/docs/assets/groq-console-home.png +0 -0
  48. package/docs/assets/groq-console-keys-login.png +0 -0
  49. package/docs/assets/groq-docs-quickstart.png +0 -0
  50. package/docs/assets/groq-docs-qwen3.6.png +0 -0
  51. package/docs/assets/groq-docs-vision.png +0 -0
  52. package/docs/assets/vision-settings.png +0 -0
  53. package/docs/dsh-desktop-install.md +67 -0
  54. package/docs/dsh-desktop-install.zh.md +67 -0
  55. package/docs/groq-qwen3.6-vision.i18n.yaml +6 -0
  56. package/docs/groq-qwen3.6-vision.md +236 -0
  57. package/docs/groq-qwen3.6-vision.zh.md +236 -0
  58. package/docs/python-runtime.i18n.yaml +6 -0
  59. package/docs/python-runtime.md +89 -0
  60. package/docs/python-runtime.zh.md +89 -0
  61. package/docs/requirements-traceability/README.i18n.yaml +6 -0
  62. package/docs/requirements-traceability/README.md +75 -0
  63. package/docs/requirements-traceability/README.zh.md +75 -0
  64. package/examples/ui-restoration/README.i18n.yaml +6 -0
  65. package/examples/ui-restoration/README.md +70 -0
  66. package/examples/ui-restoration/README.zh.md +70 -0
  67. package/examples/ui-restoration/assets/final-heatmap.png +0 -0
  68. package/examples/ui-restoration/assets/final-report.json +83 -0
  69. package/examples/ui-restoration/assets/implementation.png +0 -0
  70. package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
  71. package/examples/ui-restoration/assets/initial-report.json +83 -0
  72. package/examples/ui-restoration/assets/initial.png +0 -0
  73. package/examples/ui-restoration/assets/metrics.json +12 -0
  74. package/examples/ui-restoration/assets/reference.png +0 -0
  75. package/examples/ui-restoration/implementation.html +94 -0
  76. package/examples/ui-restoration/initial.html +57 -0
  77. package/lib/artifact-access.js +369 -0
  78. package/lib/artifact-access.js.map +1 -0
  79. package/lib/artifacts.js +56 -0
  80. package/lib/artifacts.js.map +1 -0
  81. package/lib/client.js +2099 -0
  82. package/lib/client.js.map +1 -0
  83. package/lib/config.js +172 -0
  84. package/lib/config.js.map +1 -0
  85. package/lib/defaults.js +6 -0
  86. package/lib/defaults.js.map +1 -0
  87. package/lib/errors.js +56 -0
  88. package/lib/errors.js.map +1 -0
  89. package/lib/evidence-cache.js +350 -0
  90. package/lib/evidence-cache.js.map +1 -0
  91. package/lib/exposure.js +247 -0
  92. package/lib/exposure.js.map +1 -0
  93. package/lib/image-input-variants.js +882 -0
  94. package/lib/image-input-variants.js.map +1 -0
  95. package/lib/index.js +105 -0
  96. package/lib/index.js.map +1 -0
  97. package/lib/paste-images.js +217 -0
  98. package/lib/paste-images.js.map +1 -0
  99. package/lib/paths.js +354 -0
  100. package/lib/paths.js.map +1 -0
  101. package/lib/plugin-update.js +1003 -0
  102. package/lib/plugin-update.js.map +1 -0
  103. package/lib/runtime-install.js +964 -0
  104. package/lib/runtime-install.js.map +1 -0
  105. package/lib/runtime-manager.js +133 -0
  106. package/lib/runtime-manager.js.map +1 -0
  107. package/lib/runtime.js +1678 -0
  108. package/lib/runtime.js.map +1 -0
  109. package/lib/skill.js +25 -0
  110. package/lib/skill.js.map +1 -0
  111. package/lib/tools.js +549 -0
  112. package/lib/tools.js.map +1 -0
  113. package/lib/types/artifact-access.d.ts +61 -0
  114. package/lib/types/artifact-access.d.ts.map +1 -0
  115. package/lib/types/artifacts.d.ts +42 -0
  116. package/lib/types/artifacts.d.ts.map +1 -0
  117. package/lib/types/client/display-config.d.ts +24 -0
  118. package/lib/types/client/display-config.d.ts.map +1 -0
  119. package/lib/types/client/index.d.ts +350 -0
  120. package/lib/types/client/index.d.ts.map +1 -0
  121. package/lib/types/client/model-variants-hider.d.ts +40 -0
  122. package/lib/types/client/model-variants-hider.d.ts.map +1 -0
  123. package/lib/types/client/paste-images.d.ts +126 -0
  124. package/lib/types/client/paste-images.d.ts.map +1 -0
  125. package/lib/types/config.d.ts +123 -0
  126. package/lib/types/config.d.ts.map +1 -0
  127. package/lib/types/defaults.d.ts +6 -0
  128. package/lib/types/defaults.d.ts.map +1 -0
  129. package/lib/types/errors.d.ts +35 -0
  130. package/lib/types/errors.d.ts.map +1 -0
  131. package/lib/types/evidence-cache.d.ts +91 -0
  132. package/lib/types/evidence-cache.d.ts.map +1 -0
  133. package/lib/types/exposure.d.ts +50 -0
  134. package/lib/types/exposure.d.ts.map +1 -0
  135. package/lib/types/image-input-variants.d.ts +144 -0
  136. package/lib/types/image-input-variants.d.ts.map +1 -0
  137. package/lib/types/index.d.ts +19 -0
  138. package/lib/types/index.d.ts.map +1 -0
  139. package/lib/types/paste-images.d.ts +73 -0
  140. package/lib/types/paste-images.d.ts.map +1 -0
  141. package/lib/types/paths.d.ts +117 -0
  142. package/lib/types/paths.d.ts.map +1 -0
  143. package/lib/types/plugin-update.d.ts +119 -0
  144. package/lib/types/plugin-update.d.ts.map +1 -0
  145. package/lib/types/runtime-install.d.ts +92 -0
  146. package/lib/types/runtime-install.d.ts.map +1 -0
  147. package/lib/types/runtime-manager.d.ts +60 -0
  148. package/lib/types/runtime-manager.d.ts.map +1 -0
  149. package/lib/types/runtime.d.ts +412 -0
  150. package/lib/types/runtime.d.ts.map +1 -0
  151. package/lib/types/skill.d.ts +14 -0
  152. package/lib/types/skill.d.ts.map +1 -0
  153. package/lib/types/tools.d.ts +35 -0
  154. package/lib/types/tools.d.ts.map +1 -0
  155. package/lib/types/upstream.d.ts +233 -0
  156. package/lib/types/upstream.d.ts.map +1 -0
  157. package/lib/types/version.d.ts +15 -0
  158. package/lib/types/version.d.ts.map +1 -0
  159. package/lib/types/web-request.d.ts +11 -0
  160. package/lib/types/web-request.d.ts.map +1 -0
  161. package/lib/types/web.d.ts +118 -0
  162. package/lib/types/web.d.ts.map +1 -0
  163. package/lib/upstream.js +860 -0
  164. package/lib/upstream.js.map +1 -0
  165. package/lib/version.js +18 -0
  166. package/lib/version.js.map +1 -0
  167. package/lib/web-request.js +29 -0
  168. package/lib/web-request.js.map +1 -0
  169. package/lib/web.js +462 -0
  170. package/lib/web.js.map +1 -0
  171. package/package.json +149 -0
  172. package/patches/vision-tools-dsh.patch +970 -0
  173. package/runtime/requirements.lock +3 -0
  174. package/src/artifact-access.ts +386 -0
  175. package/src/artifacts.ts +85 -0
  176. package/src/client/display-config.ts +62 -0
  177. package/src/client/index.tsx +1580 -0
  178. package/src/client/model-variants-hider.ts +159 -0
  179. package/src/client/paste-images.tsx +747 -0
  180. package/src/config.ts +290 -0
  181. package/src/defaults.ts +5 -0
  182. package/src/errors.ts +62 -0
  183. package/src/evidence-cache.ts +418 -0
  184. package/src/exposure.ts +265 -0
  185. package/src/image-input-variants.ts +996 -0
  186. package/src/index.ts +141 -0
  187. package/src/paste-images.ts +286 -0
  188. package/src/paths.ts +383 -0
  189. package/src/plugin-update.ts +1143 -0
  190. package/src/runtime-install.ts +1161 -0
  191. package/src/runtime-manager.ts +174 -0
  192. package/src/runtime.ts +2189 -0
  193. package/src/skill.ts +35 -0
  194. package/src/tools.ts +691 -0
  195. package/src/upstream.ts +1078 -0
  196. package/src/version.ts +37 -0
  197. package/src/web-request.ts +27 -0
  198. package/src/web.ts +594 -0
  199. package/vendor/agent-vision-toolkit/CHANGELOG.md +30 -0
  200. package/vendor/agent-vision-toolkit/LICENSE +21 -0
  201. package/vendor/agent-vision-toolkit/README.md +405 -0
  202. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +94 -0
  203. package/vendor/agent-vision-toolkit/bin/crop +90 -0
  204. package/vendor/agent-vision-toolkit/bin/detect +13 -0
  205. package/vendor/agent-vision-toolkit/bin/glance +93 -0
  206. package/vendor/agent-vision-toolkit/bin/ground +13 -0
  207. package/vendor/agent-vision-toolkit/bin/trace +129 -0
  208. package/vendor/agent-vision-toolkit/detect.py +60 -0
  209. package/vendor/agent-vision-toolkit/ground.py +267 -0
  210. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
  211. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
  212. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +420 -0
  213. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  214. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
  215. package/vendor/agent-vision-toolkit/tests/test_vision_client.py +450 -0
  216. package/vendor/agent-vision-toolkit/vision_client.py +310 -0
@@ -0,0 +1,420 @@
1
+ #!/usr/bin/env python3
2
+ """Screenshot an HTML file with headless Chrome/Chromium/Edge.
3
+
4
+ The default path intentionally remains the small Chrome CLI wrapper used by
5
+ the original upstream tool. ``--full-page`` uses Chrome DevTools Protocol
6
+ instead: the requested viewport remains the layout viewport while the page's
7
+ CSS scroll height is measured and captured in one pass.
8
+ """
9
+
10
+ import argparse
11
+ import base64
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import shutil
16
+ import socket
17
+ import struct
18
+ import subprocess
19
+ import tempfile
20
+ import time
21
+ from pathlib import Path
22
+ from urllib.error import URLError
23
+ from urllib.parse import urlparse
24
+ from urllib.request import urlopen
25
+
26
+
27
+ CHROME_CANDIDATES = (
28
+ # macOS
29
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
30
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
31
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
32
+ # Linux
33
+ "google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
34
+ "microsoft-edge", "brave-browser",
35
+ )
36
+
37
+
38
+ def find_chrome() -> str | None:
39
+ for candidate in CHROME_CANDIDATES:
40
+ path = shutil.which(candidate)
41
+ if path:
42
+ return path
43
+ if candidate.startswith("/") and Path(candidate).is_file():
44
+ return candidate
45
+ for env in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
46
+ base = os.environ.get(env)
47
+ if not base:
48
+ continue
49
+ for sub in ("Google/Chrome/Application/chrome.exe",
50
+ "Microsoft/Edge/Application/msedge.exe"):
51
+ path = Path(base) / sub
52
+ if path.is_file():
53
+ return str(path)
54
+ return None
55
+
56
+
57
+ def default_output(source: str) -> str:
58
+ if source.startswith(("http://", "https://", "file://", "data:")):
59
+ stem = Path(urlparse(source).path).stem
60
+ return f"{stem or 'page'}.png"
61
+ return f"{Path(source).stem}.png"
62
+
63
+
64
+ def reserve_port() -> int:
65
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
66
+ listener.bind(("127.0.0.1", 0))
67
+ return int(listener.getsockname()[1])
68
+
69
+
70
+ def read_http_json(port: int, path: str) -> object:
71
+ with urlopen(f"http://127.0.0.1:{port}{path}", timeout=1.0) as response:
72
+ return json.loads(response.read().decode("utf-8"))
73
+
74
+
75
+ class DevToolsSocket:
76
+ """Minimal RFC 6455 client for the Chrome DevTools Protocol."""
77
+
78
+ def __init__(self, websocket_url: str):
79
+ parsed = urlparse(websocket_url)
80
+ if parsed.scheme != "ws" or parsed.hostname is None or parsed.port is None:
81
+ raise RuntimeError("invalid DevTools websocket URL")
82
+ self.socket = socket.create_connection((parsed.hostname, parsed.port), timeout=5.0)
83
+ self.socket.settimeout(5.0)
84
+ self.buffer = bytearray()
85
+ key = base64.b64encode(os.urandom(16)).decode("ascii")
86
+ request = (
87
+ f"GET {parsed.path or '/'} HTTP/1.1\r\n"
88
+ f"Host: {parsed.hostname}:{parsed.port}\r\n"
89
+ "Upgrade: websocket\r\n"
90
+ "Connection: Upgrade\r\n"
91
+ f"Sec-WebSocket-Key: {key}\r\n"
92
+ "Sec-WebSocket-Version: 13\r\n\r\n"
93
+ ).encode("ascii")
94
+ self.socket.sendall(request)
95
+ response = self._read_until(b"\r\n\r\n")
96
+ if not response.startswith(b"HTTP/1.1 101"):
97
+ raise RuntimeError("Chrome did not accept the DevTools websocket")
98
+ expected_accept = base64.b64encode(hashlib.sha1(
99
+ f"{key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11".encode("ascii")
100
+ ).digest()).decode("ascii")
101
+ headers = response.decode("latin-1").split("\r\n")
102
+ accept = next((line.split(":", 1)[1].strip() for line in headers
103
+ if line.lower().startswith("sec-websocket-accept:")), None)
104
+ if accept != expected_accept:
105
+ raise RuntimeError("Chrome returned an invalid DevTools websocket handshake")
106
+ self.next_id = 0
107
+
108
+ def _read_until(self, marker: bytes) -> bytes:
109
+ data = bytearray()
110
+ while marker not in data:
111
+ chunk = self.socket.recv(4096)
112
+ if not chunk:
113
+ raise RuntimeError("DevTools websocket closed during handshake")
114
+ data.extend(chunk)
115
+ boundary = data.index(marker) + len(marker)
116
+ self.buffer.extend(data[boundary:])
117
+ return bytes(data[:boundary])
118
+
119
+ def _send_frame(self, opcode: int, payload: bytes) -> None:
120
+ mask = os.urandom(4)
121
+ masked = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
122
+ length = len(masked)
123
+ if length < 126:
124
+ header = struct.pack("!BB", 0x80 | opcode, 0x80 | length)
125
+ elif length < 65536:
126
+ header = struct.pack("!BBH", 0x80 | opcode, 0x80 | 126, length)
127
+ else:
128
+ header = struct.pack("!BBQ", 0x80 | opcode, 0x80 | 127, length)
129
+ self.socket.sendall(header + mask + masked)
130
+
131
+ def _read_frame(self) -> tuple[bool, int, bytes]:
132
+ header = self._read_exact(2)
133
+ first, second = header
134
+ final = (first & 0x80) != 0
135
+ opcode = first & 0x0F
136
+ length = second & 0x7F
137
+ masked = (second & 0x80) != 0
138
+ if length == 126:
139
+ length = struct.unpack("!H", self._read_exact(2))[0]
140
+ elif length == 127:
141
+ length = struct.unpack("!Q", self._read_exact(8))[0]
142
+ mask = self._read_exact(4) if masked else None
143
+ payload = self._read_exact(length)
144
+ if mask is not None:
145
+ payload = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
146
+ return final, opcode, payload
147
+
148
+ def _read_message(self) -> tuple[int, bytes]:
149
+ fragments: list[bytes] = []
150
+ message_opcode: int | None = None
151
+ while True:
152
+ final, opcode, payload = self._read_frame()
153
+ if opcode == 0x9:
154
+ self._send_frame(0xA, payload)
155
+ continue
156
+ if opcode == 0x8:
157
+ raise RuntimeError("DevTools websocket closed")
158
+ if opcode in (0x1, 0x2):
159
+ fragments = [payload]
160
+ message_opcode = opcode
161
+ elif opcode == 0x0 and message_opcode is not None:
162
+ fragments.append(payload)
163
+ else:
164
+ continue
165
+ if final and message_opcode is not None:
166
+ return message_opcode, b"".join(fragments)
167
+
168
+ def _read_exact(self, length: int) -> bytes:
169
+ data = bytearray()
170
+ if self.buffer:
171
+ take = min(length, len(self.buffer))
172
+ data.extend(self.buffer[:take])
173
+ del self.buffer[:take]
174
+ while len(data) < length:
175
+ chunk = self.socket.recv(length - len(data))
176
+ if not chunk:
177
+ raise RuntimeError("DevTools websocket closed unexpectedly")
178
+ data.extend(chunk)
179
+ return bytes(data)
180
+
181
+ def command(self, method: str, params: dict | None = None) -> dict:
182
+ self.next_id += 1
183
+ request_id = self.next_id
184
+ request = {"id": request_id, "method": method}
185
+ if params is not None:
186
+ request["params"] = params
187
+ self._send_frame(0x1, json.dumps(request, separators=(",", ":")).encode("utf-8"))
188
+ while True:
189
+ opcode, payload = self._read_message()
190
+ if opcode != 0x1:
191
+ continue
192
+ message = json.loads(payload.decode("utf-8"))
193
+ if message.get("id") != request_id:
194
+ continue
195
+ if "error" in message:
196
+ raise RuntimeError(f"{method}: {message['error'].get('message', 'CDP command failed')}")
197
+ return message.get("result", {})
198
+
199
+ def close(self) -> None:
200
+ try:
201
+ self._send_frame(0x8, b"")
202
+ except OSError:
203
+ pass
204
+ self.socket.close()
205
+
206
+
207
+ def evaluate(client: DevToolsSocket, expression: str) -> object:
208
+ result = client.command("Runtime.evaluate", {
209
+ "expression": expression,
210
+ "returnByValue": True,
211
+ "awaitPromise": True,
212
+ })
213
+ if "exceptionDetails" in result:
214
+ raise RuntimeError(result["exceptionDetails"].get("text", "Runtime.evaluate failed"))
215
+ remote = result.get("result", {})
216
+ if remote.get("subtype") == "error":
217
+ raise RuntimeError(remote.get("description", "Runtime.evaluate failed"))
218
+ return remote.get("value")
219
+
220
+
221
+ def wait_for_target(port: int, deadline: float) -> dict:
222
+ last_error: Exception | None = None
223
+ while time.monotonic() < deadline:
224
+ try:
225
+ targets = read_http_json(port, "/json/list")
226
+ if isinstance(targets, list):
227
+ for target in targets:
228
+ if isinstance(target, dict) and target.get("type") == "page" and target.get("webSocketDebuggerUrl"):
229
+ return target
230
+ except (OSError, URLError, ValueError) as error:
231
+ last_error = error
232
+ time.sleep(0.05)
233
+ detail = f": {last_error}" if last_error else ""
234
+ raise RuntimeError(f"timed out waiting for Chrome DevTools{detail}")
235
+
236
+
237
+ def wait_for_ready(client: DevToolsSocket, deadline: float) -> None:
238
+ while time.monotonic() < deadline:
239
+ if evaluate(client, "document.readyState") == "complete":
240
+ return
241
+ time.sleep(0.05)
242
+ raise RuntimeError("timed out waiting for the HTML document")
243
+
244
+
245
+ def pause_for_frame(seconds: float) -> None:
246
+ time.sleep(max(0.02, seconds))
247
+
248
+
249
+ def capture_full_page(chrome: str, source: str, output: Path, width: int, height: int,
250
+ scale: int, wait_ms: int, max_pixels: int | None) -> int:
251
+ port = reserve_port()
252
+ guarded_profile = os.environ.get("DSH_VISION_CHROME_PROFILE")
253
+ profile = guarded_profile or tempfile.mkdtemp(prefix="dsh-vision-chrome-")
254
+ process: subprocess.Popen | None = None
255
+ client: DevToolsSocket | None = None
256
+ try:
257
+ command = [
258
+ chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars",
259
+ "--no-first-run", "--no-default-browser-check",
260
+ "--blink-settings=imagesLazyLoadingEnabled=false",
261
+ f"--window-size={width},{height}",
262
+ f"--remote-debugging-port={port}",
263
+ "--remote-debugging-address=127.0.0.1",
264
+ ]
265
+ if guarded_profile is None:
266
+ command.append(f"--user-data-dir={profile}")
267
+ if scale != 1:
268
+ command.append(f"--force-device-scale-factor={scale}")
269
+ command.append(source)
270
+ process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
271
+ target = wait_for_target(port, time.monotonic() + 15.0)
272
+ client = DevToolsSocket(str(target["webSocketDebuggerUrl"]))
273
+ client.command("Page.enable")
274
+ client.command("Runtime.enable")
275
+ client.command("Emulation.setDeviceMetricsOverride", {
276
+ "width": width,
277
+ "height": height,
278
+ "deviceScaleFactor": scale,
279
+ "mobile": False,
280
+ "screenWidth": width,
281
+ "screenHeight": height,
282
+ })
283
+ client.command("Page.navigate", {"url": source})
284
+ wait_for_ready(client, time.monotonic() + 15.0)
285
+ if wait_ms > 0:
286
+ pause_for_frame(wait_ms / 1000.0)
287
+
288
+ # Keep the requested viewport fixed. Scrolling, rather than growing
289
+ # the viewport, wakes IO/reveal handlers without changing vh/svh.
290
+ evaluate(client, """
291
+ (() => {
292
+ document.documentElement.style.scrollBehavior = 'auto';
293
+ if (document.body) document.body.style.scrollBehavior = 'auto';
294
+ window.scrollTo(0, 0);
295
+ return true;
296
+ })()
297
+ """)
298
+ page_height = int(evaluate(client, """
299
+ Math.max(document.documentElement.scrollHeight,
300
+ document.body ? document.body.scrollHeight : 0)
301
+ """) or 0)
302
+ for _ in range(2):
303
+ measured_before_sweep = page_height
304
+ for position in range(0, page_height, max(1, height)):
305
+ evaluate(client, f"window.scrollTo(0, {position}); true")
306
+ pause_for_frame(0.12)
307
+ evaluate(client, f"window.scrollTo(0, {page_height}); true")
308
+ pause_for_frame(0.12)
309
+ page_height = int(evaluate(client, """
310
+ Math.max(document.documentElement.scrollHeight,
311
+ document.body ? document.body.scrollHeight : 0)
312
+ """) or 0)
313
+ if page_height <= measured_before_sweep:
314
+ break
315
+ evaluate(client, """
316
+ Promise.race([
317
+ Promise.all(Array.from(document.images)
318
+ .filter(image => !image.complete)
319
+ .map(image => new Promise(resolve => {
320
+ image.addEventListener('load', resolve, {once: true});
321
+ image.addEventListener('error', resolve, {once: true});
322
+ }))),
323
+ new Promise(resolve => setTimeout(resolve, 3000))
324
+ ]).then(() => true)
325
+ """)
326
+ evaluate(client, "window.scrollTo(0, 0); true")
327
+ pause_for_frame(0.5)
328
+ page_height = int(evaluate(client, """
329
+ Math.max(document.documentElement.scrollHeight,
330
+ document.body ? document.body.scrollHeight : 0)
331
+ """) or 0)
332
+ if page_height <= 0:
333
+ raise RuntimeError("document has no measurable scroll height")
334
+ if max_pixels is not None and width * page_height * scale * scale > max_pixels:
335
+ raise RuntimeError(f"full-page screenshot would exceed maxImagePixels ({width * page_height * scale * scale} > {max_pixels})")
336
+ result = client.command("Page.captureScreenshot", {
337
+ "format": "png",
338
+ "fromSurface": True,
339
+ "captureBeyondViewport": True,
340
+ "clip": {"x": 0, "y": 0, "width": width, "height": page_height, "scale": 1},
341
+ })
342
+ data = result.get("data")
343
+ if not isinstance(data, str):
344
+ raise RuntimeError("Chrome did not return screenshot data")
345
+ output.write_bytes(base64.b64decode(data))
346
+ return page_height
347
+ finally:
348
+ if client is not None:
349
+ client.close()
350
+ if process is not None and process.poll() is None:
351
+ process.terminate()
352
+ try:
353
+ process.wait(timeout=2)
354
+ except subprocess.TimeoutExpired:
355
+ process.kill()
356
+ process.wait(timeout=2)
357
+ if guarded_profile is None:
358
+ shutil.rmtree(profile, ignore_errors=True)
359
+
360
+
361
+ def main() -> None:
362
+ parser = argparse.ArgumentParser(
363
+ prog="html_shot",
364
+ description="Screenshot an HTML file (or URL) to PNG with headless Chrome/Chromium/Edge",
365
+ )
366
+ parser.add_argument("source", help="path to a local .html file, or an http(s):// URL")
367
+ parser.add_argument("-o", "--output", help="output PNG path (default: <source-stem>.png in the current directory)")
368
+ parser.add_argument("--width", type=int, default=1280, help="viewport width in CSS pixels (default: 1280)")
369
+ parser.add_argument("--height", type=int, default=800, help="viewport height in CSS pixels (default: 800)")
370
+ parser.add_argument("--scale", type=int, default=1,
371
+ help="device scale factor: 2 makes the PNG 2x for small text (default: 1)")
372
+ parser.add_argument("--wait-ms", type=int, default=0,
373
+ help="virtual time budget in ms before capturing (default: 0, capture immediately)")
374
+ parser.add_argument("--full-page", action="store_true",
375
+ help="capture the full CSS scroll height while keeping the requested viewport")
376
+ parser.add_argument("--max-pixels", type=int,
377
+ help="reject full-page captures larger than this many output pixels")
378
+ args = parser.parse_args()
379
+
380
+ chrome = find_chrome()
381
+ if not chrome:
382
+ parser.exit(1, "html_shot: no Chrome/Chromium/Edge found; install one and retry\n")
383
+
384
+ source = args.source
385
+ if not source.startswith(("http://", "https://", "file://", "data:")):
386
+ path = Path(source).expanduser()
387
+ if not path.is_file():
388
+ parser.exit(1, f"html_shot: file not found: {path}\n")
389
+ source = path.resolve().as_uri()
390
+
391
+ output = Path(args.output).expanduser().resolve() if args.output else Path(default_output(source)).resolve()
392
+ if args.full_page:
393
+ try:
394
+ page_height = capture_full_page(chrome, source, output, args.width, args.height, args.scale, args.wait_ms, args.max_pixels)
395
+ except Exception as error:
396
+ parser.exit(1, f"html_shot: full-page capture failed: {error}\n")
397
+ print(f"wrote {output} ({args.width * args.scale}x{page_height * args.scale}; pageHeight={page_height})")
398
+ return
399
+
400
+ command = [
401
+ chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars",
402
+ "--no-first-run", "--no-default-browser-check",
403
+ f"--window-size={args.width},{args.height}",
404
+ f"--screenshot={output}",
405
+ ]
406
+ if args.scale != 1:
407
+ command.append(f"--force-device-scale-factor={args.scale}")
408
+ if args.wait_ms > 0:
409
+ command.append(f"--virtual-time-budget={args.wait_ms}")
410
+ command.append(source)
411
+
412
+ result = subprocess.run(command, text=True, capture_output=True)
413
+ if result.returncode != 0 or not output.is_file():
414
+ message = result.stderr.strip() or result.stdout.strip() or f"chrome exited with code {result.returncode}"
415
+ parser.exit(1, f"html_shot: capture failed: {message}\n")
416
+ print(f"wrote {output} ({args.width * args.scale}x{args.height * args.scale})")
417
+
418
+
419
+ if __name__ == "__main__":
420
+ main()