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.
@@ -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