webgpu-inspector-cli 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.
- webgpu_inspector_cli/__init__.py +3 -0
- webgpu_inspector_cli/__main__.py +5 -0
- webgpu_inspector_cli/commands/__init__.py +0 -0
- webgpu_inspector_cli/commands/browser.py +106 -0
- webgpu_inspector_cli/commands/capture.py +197 -0
- webgpu_inspector_cli/commands/errors.py +82 -0
- webgpu_inspector_cli/commands/objects.py +129 -0
- webgpu_inspector_cli/commands/shaders.py +108 -0
- webgpu_inspector_cli/commands/status.py +79 -0
- webgpu_inspector_cli/core/__init__.py +0 -0
- webgpu_inspector_cli/core/bridge.py +189 -0
- webgpu_inspector_cli/core/session.py +40 -0
- webgpu_inspector_cli/js/collector.js +439 -0
- webgpu_inspector_cli/skills/SKILL.md +87 -0
- webgpu_inspector_cli/tests/__init__.py +0 -0
- webgpu_inspector_cli/tests/test_core.py +151 -0
- webgpu_inspector_cli/tests/test_full_e2e.py +200 -0
- webgpu_inspector_cli/utils/__init__.py +0 -0
- webgpu_inspector_cli/utils/repl_skin.py +523 -0
- webgpu_inspector_cli/webgpu_inspector_cli.py +91 -0
- webgpu_inspector_cli-0.1.0.dist-info/METADATA +14 -0
- webgpu_inspector_cli-0.1.0.dist-info/RECORD +25 -0
- webgpu_inspector_cli-0.1.0.dist-info/WHEEL +5 -0
- webgpu_inspector_cli-0.1.0.dist-info/entry_points.txt +2 -0
- webgpu_inspector_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Runtime monitoring commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from webgpu_inspector_cli.core.bridge import require_bridge
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _format_bytes(n):
|
|
10
|
+
if n is None or n == 0:
|
|
11
|
+
return "0 B"
|
|
12
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
13
|
+
if abs(n) < 1024:
|
|
14
|
+
return f"{n:.1f} {unit}" if unit != "B" else f"{n} {unit}"
|
|
15
|
+
n /= 1024
|
|
16
|
+
return f"{n:.1f} TB"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group()
|
|
20
|
+
def status():
|
|
21
|
+
"""Runtime monitoring."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@status.command()
|
|
26
|
+
@click.pass_context
|
|
27
|
+
def summary(ctx):
|
|
28
|
+
"""Overall GPU state summary."""
|
|
29
|
+
bridge = require_bridge()
|
|
30
|
+
result = bridge.query("getSummary")
|
|
31
|
+
|
|
32
|
+
if ctx.obj.get("json"):
|
|
33
|
+
click.echo(json.dumps(result, indent=2))
|
|
34
|
+
else:
|
|
35
|
+
click.echo(f"Objects: {result['objectCount']}")
|
|
36
|
+
if result.get("typeCounts"):
|
|
37
|
+
for t, c in sorted(result["typeCounts"].items()):
|
|
38
|
+
click.echo(f" {t}: {c}")
|
|
39
|
+
click.echo(f"Errors: {result['errorCount']}")
|
|
40
|
+
fps = result.get("fps", 0)
|
|
41
|
+
dt = result.get("deltaTime", -1)
|
|
42
|
+
if dt > 0:
|
|
43
|
+
click.echo(f"FPS: {fps} ({dt:.1f}ms)")
|
|
44
|
+
else:
|
|
45
|
+
click.echo("FPS: -- (no frame data)")
|
|
46
|
+
click.echo(f"Texture memory: {_format_bytes(result['totalTextureMemory'])}")
|
|
47
|
+
click.echo(f"Buffer memory: {_format_bytes(result['totalBufferMemory'])}")
|
|
48
|
+
click.echo(f"Total memory: {_format_bytes(result['totalMemory'])}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@status.command()
|
|
52
|
+
@click.pass_context
|
|
53
|
+
def fps(ctx):
|
|
54
|
+
"""Show current frame rate."""
|
|
55
|
+
bridge = require_bridge()
|
|
56
|
+
result = bridge.query("getFrameRate")
|
|
57
|
+
|
|
58
|
+
if ctx.obj.get("json"):
|
|
59
|
+
click.echo(json.dumps(result))
|
|
60
|
+
else:
|
|
61
|
+
if result["deltaTime"] <= 0:
|
|
62
|
+
click.echo("FPS: -- (no frame data yet)")
|
|
63
|
+
else:
|
|
64
|
+
click.echo(f"FPS: {result['fps']} ({result['deltaTime']:.1f}ms per frame)")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@status.command("memory")
|
|
68
|
+
@click.pass_context
|
|
69
|
+
def memory_cmd(ctx):
|
|
70
|
+
"""Show GPU memory breakdown."""
|
|
71
|
+
bridge = require_bridge()
|
|
72
|
+
mem = bridge.query("getMemoryUsage")
|
|
73
|
+
|
|
74
|
+
if ctx.obj.get("json"):
|
|
75
|
+
click.echo(json.dumps(mem, indent=2))
|
|
76
|
+
else:
|
|
77
|
+
click.echo(f"Texture memory: {_format_bytes(mem['totalTextureMemory'])}")
|
|
78
|
+
click.echo(f"Buffer memory: {_format_bytes(mem['totalBufferMemory'])}")
|
|
79
|
+
click.echo(f"Total: {_format_bytes(mem['totalMemory'])}")
|
|
File without changes
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Playwright CDP bridge for WebGPU Inspector injection and communication."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from playwright.sync_api import sync_playwright, Browser, Page, BrowserContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _find_inspector_js():
|
|
12
|
+
"""Locate the built webgpu_inspector_loader.js from the submodule."""
|
|
13
|
+
# Walk up from this file to find the repo root
|
|
14
|
+
pkg_dir = Path(__file__).resolve().parent.parent # webgpu_inspector_cli/
|
|
15
|
+
repo_root = pkg_dir.parent.parent # webgpu-inspector-cli/
|
|
16
|
+
loader_path = repo_root / "webgpu_inspector" / "extensions" / "chrome" / "webgpu_inspector_loader.js"
|
|
17
|
+
if loader_path.exists():
|
|
18
|
+
return loader_path
|
|
19
|
+
raise FileNotFoundError(
|
|
20
|
+
f"Could not find webgpu_inspector_loader.js at {loader_path}. "
|
|
21
|
+
"Make sure the webgpu_inspector submodule is initialized: "
|
|
22
|
+
"git submodule update --init"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _find_collector_js():
|
|
27
|
+
"""Locate the collector.js script bundled with this package."""
|
|
28
|
+
js_dir = Path(__file__).resolve().parent.parent / "js"
|
|
29
|
+
collector_path = js_dir / "collector.js"
|
|
30
|
+
if collector_path.exists():
|
|
31
|
+
return collector_path
|
|
32
|
+
raise FileNotFoundError(f"Could not find collector.js at {collector_path}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Bridge:
|
|
36
|
+
"""Manages browser lifecycle, inspector injection, and communication."""
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self._playwright = None
|
|
40
|
+
self._browser: Browser | None = None
|
|
41
|
+
self._context: BrowserContext | None = None
|
|
42
|
+
self._page: Page | None = None
|
|
43
|
+
self._inspector_injected = False
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def page(self) -> Page | None:
|
|
47
|
+
return self._page
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_connected(self) -> bool:
|
|
51
|
+
return self._page is not None and not self._page.is_closed()
|
|
52
|
+
|
|
53
|
+
def launch(self, url: str, headless: bool = False, gpu_backend: str | None = None):
|
|
54
|
+
"""Launch browser, navigate to URL, and inject the inspector."""
|
|
55
|
+
self._playwright = sync_playwright().start()
|
|
56
|
+
|
|
57
|
+
args = [
|
|
58
|
+
"--enable-unsafe-webgpu",
|
|
59
|
+
"--enable-features=Vulkan",
|
|
60
|
+
]
|
|
61
|
+
if gpu_backend:
|
|
62
|
+
args.append(f"--use-gl={gpu_backend}")
|
|
63
|
+
|
|
64
|
+
self._browser = self._playwright.chromium.launch(
|
|
65
|
+
headless=headless,
|
|
66
|
+
args=args,
|
|
67
|
+
)
|
|
68
|
+
self._context = self._browser.new_context()
|
|
69
|
+
self._page = self._context.new_page()
|
|
70
|
+
self._page.goto(url, wait_until="domcontentloaded")
|
|
71
|
+
self._inject()
|
|
72
|
+
|
|
73
|
+
def navigate(self, url: str):
|
|
74
|
+
"""Navigate to a new URL and re-inject the inspector."""
|
|
75
|
+
if not self.is_connected:
|
|
76
|
+
raise RuntimeError("No browser session. Call launch() first.")
|
|
77
|
+
self._inspector_injected = False
|
|
78
|
+
self._page.goto(url, wait_until="domcontentloaded")
|
|
79
|
+
self._inject()
|
|
80
|
+
|
|
81
|
+
def close(self):
|
|
82
|
+
"""Shut down the browser and clean up resources."""
|
|
83
|
+
if self._browser:
|
|
84
|
+
self._browser.close()
|
|
85
|
+
self._browser = None
|
|
86
|
+
if self._playwright:
|
|
87
|
+
self._playwright.stop()
|
|
88
|
+
self._playwright = None
|
|
89
|
+
self._page = None
|
|
90
|
+
self._context = None
|
|
91
|
+
self._inspector_injected = False
|
|
92
|
+
|
|
93
|
+
def screenshot(self, output_path: str, full_page: bool = False) -> str:
|
|
94
|
+
"""Take a screenshot of the current page."""
|
|
95
|
+
if not self.is_connected:
|
|
96
|
+
raise RuntimeError("No browser session.")
|
|
97
|
+
self._page.screenshot(path=output_path, full_page=full_page)
|
|
98
|
+
return output_path
|
|
99
|
+
|
|
100
|
+
def _inject(self):
|
|
101
|
+
"""Inject the inspector and collector scripts into the page."""
|
|
102
|
+
if self._inspector_injected:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# 1. Inject the built webgpu_inspector_loader.js
|
|
106
|
+
loader_js = _find_inspector_js().read_text()
|
|
107
|
+
self._page.evaluate(loader_js)
|
|
108
|
+
|
|
109
|
+
# 2. Inject our collector.js
|
|
110
|
+
collector_js = _find_collector_js().read_text()
|
|
111
|
+
self._page.evaluate(collector_js)
|
|
112
|
+
|
|
113
|
+
# 3. Dispatch the start_inspection event to activate the inspector
|
|
114
|
+
self._page.evaluate("""() => {
|
|
115
|
+
window.dispatchEvent(new CustomEvent("__WebGPUInspector", {
|
|
116
|
+
detail: {
|
|
117
|
+
__webgpuInspector: true,
|
|
118
|
+
action: "webgpu_inspector_start_inspection"
|
|
119
|
+
}
|
|
120
|
+
}));
|
|
121
|
+
}""")
|
|
122
|
+
|
|
123
|
+
self._inspector_injected = True
|
|
124
|
+
|
|
125
|
+
def query(self, fn_name: str, *args) -> object:
|
|
126
|
+
"""Call a collector query function and return the result."""
|
|
127
|
+
if not self.is_connected:
|
|
128
|
+
raise RuntimeError("No browser session.")
|
|
129
|
+
args_json = json.dumps(args)
|
|
130
|
+
result = self._page.evaluate(f"() => window.__wgi.{fn_name}(...{args_json})")
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
def send_action(self, action: str, data: dict | None = None):
|
|
134
|
+
"""Dispatch a PanelAction to the inspector running in the page."""
|
|
135
|
+
if not self.is_connected:
|
|
136
|
+
raise RuntimeError("No browser session.")
|
|
137
|
+
message = {
|
|
138
|
+
"__webgpuInspector": True,
|
|
139
|
+
"action": action,
|
|
140
|
+
}
|
|
141
|
+
if data:
|
|
142
|
+
message.update(data)
|
|
143
|
+
self._page.evaluate("""(msg) => {
|
|
144
|
+
window.dispatchEvent(new CustomEvent("__WebGPUInspector", {
|
|
145
|
+
detail: msg
|
|
146
|
+
}));
|
|
147
|
+
}""", message)
|
|
148
|
+
|
|
149
|
+
def wait_for_condition(self, js_expression: str, timeout: float = 30.0) -> object:
|
|
150
|
+
"""Wait for a JS expression to return a truthy value."""
|
|
151
|
+
if not self.is_connected:
|
|
152
|
+
raise RuntimeError("No browser session.")
|
|
153
|
+
self._page.wait_for_function(js_expression, timeout=timeout * 1000)
|
|
154
|
+
return self._page.evaluate(js_expression)
|
|
155
|
+
|
|
156
|
+
def get_browser_info(self) -> dict:
|
|
157
|
+
"""Get browser and GPU information."""
|
|
158
|
+
if not self.is_connected:
|
|
159
|
+
raise RuntimeError("No browser session.")
|
|
160
|
+
return self._page.evaluate("""() => {
|
|
161
|
+
return {
|
|
162
|
+
url: window.location.href,
|
|
163
|
+
title: document.title,
|
|
164
|
+
userAgent: navigator.userAgent,
|
|
165
|
+
gpu: navigator.gpu ? 'available' : 'unavailable',
|
|
166
|
+
};
|
|
167
|
+
}""")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# Singleton bridge instance shared across CLI commands
|
|
171
|
+
_bridge: Bridge | None = None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def get_bridge() -> Bridge:
|
|
175
|
+
"""Get or create the global bridge instance."""
|
|
176
|
+
global _bridge
|
|
177
|
+
if _bridge is None:
|
|
178
|
+
_bridge = Bridge()
|
|
179
|
+
return _bridge
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def require_bridge() -> Bridge:
|
|
183
|
+
"""Get the bridge, raising an error if no session is active."""
|
|
184
|
+
bridge = get_bridge()
|
|
185
|
+
if not bridge.is_connected:
|
|
186
|
+
raise RuntimeError(
|
|
187
|
+
"No active browser session. Run 'browser launch --url <URL>' first."
|
|
188
|
+
)
|
|
189
|
+
return bridge
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Session state management with undo/redo for shader edits."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Session:
|
|
5
|
+
"""Tracks mutable state (shader edits) for undo/redo support."""
|
|
6
|
+
|
|
7
|
+
def __init__(self):
|
|
8
|
+
# shader_id -> list of previous code versions (stack)
|
|
9
|
+
self._shader_history: dict[int, list[str]] = {}
|
|
10
|
+
|
|
11
|
+
def push_shader_edit(self, shader_id: int, original_code: str):
|
|
12
|
+
"""Save the original code before a shader edit for undo."""
|
|
13
|
+
if shader_id not in self._shader_history:
|
|
14
|
+
self._shader_history[shader_id] = []
|
|
15
|
+
self._shader_history[shader_id].append(original_code)
|
|
16
|
+
|
|
17
|
+
def pop_shader_edit(self, shader_id: int) -> str | None:
|
|
18
|
+
"""Pop the last saved code for undo. Returns None if no history."""
|
|
19
|
+
stack = self._shader_history.get(shader_id, [])
|
|
20
|
+
if stack:
|
|
21
|
+
return stack.pop()
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
def clear_shader_edits(self, shader_id: int):
|
|
25
|
+
"""Clear edit history for a shader."""
|
|
26
|
+
self._shader_history.pop(shader_id, None)
|
|
27
|
+
|
|
28
|
+
def has_shader_edits(self, shader_id: int) -> bool:
|
|
29
|
+
return bool(self._shader_history.get(shader_id))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_session: Session | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_session() -> Session:
|
|
36
|
+
"""Get or create the global session instance."""
|
|
37
|
+
global _session
|
|
38
|
+
if _session is None:
|
|
39
|
+
_session = Session()
|
|
40
|
+
return _session
|