Skip to content

Commit 002d04d

Browse files
committed
utilities for headless screenshots and testing
1 parent d9aca09 commit 002d04d

1 file changed

Lines changed: 115 additions & 64 deletions

File tree

webgpu/testing.py

Lines changed: 115 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def _configure_dirs(webgpu_env):
2424
import shutil
2525
import tempfile
2626
import threading
27+
from contextlib import contextmanager
2728
from functools import partial
2829
from http.server import HTTPServer, SimpleHTTPRequestHandler
2930
from pathlib import Path
@@ -137,6 +138,64 @@ def log_message(self, format, *args):
137138
pass
138139

139140

141+
def readback_scene(scene):
142+
"""Read back a scene's current rendered frame as an HxWx4 RGBA array.
143+
144+
Uses the JS render engine (the default backend): it renders into an
145+
offscreen capture target and the texture is read back on the JS side via
146+
``window._doReadback`` (which the test page must provide — see
147+
:data:`_READBACK_JS`). Falls back to the legacy Python render path
148+
(render to ``target_texture`` + read it back) when no engine is active.
149+
150+
Reusable outside this module (e.g. by ngapp's e2e helpers) so the readback
151+
lives in one place.
152+
"""
153+
import numpy as np
154+
155+
from webgpu import platform
156+
157+
engine = getattr(scene, "_js_engine", None)
158+
if engine is None and getattr(scene, "_use_js_engine", False):
159+
try:
160+
scene._install_live_engine()
161+
engine = getattr(scene, "_js_engine", None)
162+
except Exception as e:
163+
print(f"warning: JS engine install failed, using Python path: {e}")
164+
engine = None
165+
166+
if engine is not None:
167+
engine.enableHeadlessCapture()
168+
w, h = scene.canvas.width, scene.canvas.height
169+
fmt = str(scene.canvas.format)
170+
# count-then-fill renderers (e.g. clipping) size their output buffer
171+
# reactively: a render's async counter readback resizes the buffer and
172+
# the next render fills it. That readback only progresses when the GPU
173+
# queue is ticked, so a plain wait isn't enough over the bridge — pump a
174+
# tiny readback after each render to drive it to convergence before we
175+
# capture (matches the interactive path, where the lag is not noticeable).
176+
for _ in range(5):
177+
engine.render()
178+
platform.js._doReadback(scene.device.handle, engine.captureTexture(), 8, 8)
179+
tex = engine.captureTexture()
180+
else:
181+
# Ensure target_texture reflects current scene state.
182+
with scene._render_mutex:
183+
scene._render_objects(to_canvas=False)
184+
tex = scene.canvas.target_texture
185+
w, h = tex.width, tex.height
186+
fmt = str(tex.format)
187+
188+
bytes_per_row = (w * 4 + 255) // 256 * 256
189+
b64_data = platform.js._doReadback(scene.device.handle, tex, w, h)
190+
191+
raw = base64.b64decode(b64_data)
192+
data = np.frombuffer(raw, dtype=np.uint8).reshape((h, bytes_per_row // 4, 4))
193+
data = data[:, :w, :]
194+
if fmt == "bgra8unorm":
195+
data = data[:, :, [2, 1, 0, 3]]
196+
return data
197+
198+
140199
# ---------------------------------------------------------------------------
141200
# WebGPUTestEnv – helpers available via the webgpu_env fixture
142201
# ---------------------------------------------------------------------------
@@ -245,58 +304,11 @@ def assert_matches_baseline(self, scene, filename, *, threshold=0.01):
245304
)
246305

247306
def readback_texture(self, scene, path):
248-
"""Read back the rendered frame via JS-side readback."""
249-
import numpy as np
307+
"""Read back the rendered frame and save it as a PNG at *path*."""
250308
from PIL import Image
251-
from webgpu import platform
252-
253-
engine = getattr(scene, '_js_engine', None)
254-
if engine is None and getattr(scene, '_use_js_engine', False):
255-
try:
256-
scene._install_live_engine()
257-
engine = getattr(scene, '_js_engine', None)
258-
except Exception as e:
259-
print(f"warning: JS engine install failed, using Python path: {e}")
260-
engine = None
261-
262-
if engine is not None:
263-
engine.enableHeadlessCapture()
264-
w, h = scene.canvas.width, scene.canvas.height
265-
fmt = str(scene.canvas.format)
266-
# count-then-fill renderers (e.g. clipping) size their output buffer
267-
# reactively: a render's async counter readback resizes the buffer and
268-
# the next render fills it. That readback only progresses when the GPU
269-
# queue is ticked, so a plain wait isn't enough over the bridge — pump a
270-
# tiny readback after each render to drive it to convergence before we
271-
# capture (matches the interactive path, where the lag is not noticeable).
272-
for _ in range(5):
273-
engine.render()
274-
platform.js._doReadback(scene.device.handle, engine.captureTexture(), 8, 8)
275-
tex = engine.captureTexture()
276-
else:
277-
# Ensure target_texture reflects current scene state.
278-
# The debounced scene.render() from _draw_scene may not have
279-
# re-run after state changes (e.g. toggling renderer.active).
280-
with scene._render_mutex:
281-
scene._render_objects(to_canvas=False)
282-
tex = scene.canvas.target_texture
283-
w, h = tex.width, tex.height
284-
fmt = str(tex.format)
285-
286-
bytes_per_row = (w * 4 + 255) // 256 * 256
287-
b64_data = platform.js._doReadback(scene.device.handle, tex, w, h)
288309

289-
raw = base64.b64decode(b64_data)
290-
data = np.frombuffer(raw, dtype=np.uint8).reshape(
291-
(h, bytes_per_row // 4, 4)
292-
)
293-
data = data[:, :w, :]
294-
295-
if fmt == "bgra8unorm":
296-
data = data[:, :, [2, 1, 0, 3]]
297-
298-
img = Image.fromarray(data[:, :, :3])
299-
img.save(str(path))
310+
data = readback_scene(scene)
311+
Image.fromarray(data[:, :, :3]).save(str(path))
300312
return path
301313

302314
def assert_min_fps(self, scene, min_fps=60, *, frames=20, warmup=5, label=None):
@@ -400,11 +412,7 @@ def _playwright():
400412

401413
@pytest.fixture(scope="session")
402414
def browser(_playwright):
403-
b = _playwright.chromium.launch(
404-
channel="chrome",
405-
headless=False,
406-
args=["--headless=new"] + CHROMIUM_WEBGPU_ARGS,
407-
)
415+
b = launch_webgpu_browser(_playwright)
408416
yield b
409417
b.close()
410418

@@ -417,9 +425,18 @@ def page(browser):
417425
p.close()
418426

419427

420-
@pytest.fixture(scope="session")
421-
def webgpu_env(browser):
422-
"""Full webgpu environment: platform + device + browser page with canvas.
428+
def launch_webgpu_browser(playwright):
429+
"""Launch a headless Chromium configured for WebGPU (Vulkan/Dawn)."""
430+
return playwright.chromium.launch(
431+
channel="chrome",
432+
headless=False,
433+
args=["--headless=new"] + CHROMIUM_WEBGPU_ARGS,
434+
)
435+
436+
437+
@contextmanager
438+
def _open_env(browser):
439+
"""Bring up the WS bridge + device on an existing browser, yield the env.
423440
424441
Threading choreography::
425442
@@ -484,7 +501,7 @@ def run_init():
484501

485502
# Navigate browser -> triggers WS connection -> unblocks platform.init
486503
test_page = browser.new_page()
487-
# Forward browser console to pytest output for debugging.
504+
# Forward browser console for debugging.
488505
test_page.on("console", lambda msg: print(f"[browser:{msg.type}] {msg.text}"))
489506
test_page.on("pageerror", lambda exc: print(f"[browser:pageerror] {exc}"))
490507
test_page.goto(f"http://127.0.0.1:{http_port}/index.html")
@@ -497,16 +514,50 @@ def run_init():
497514

498515
init_device_sync()
499516

500-
# Flush any init intermediates so tests start with a clean release queue
517+
# Flush any init intermediates so we start with a clean release queue
501518
import gc
502519
gc.collect()
503520
gc.collect()
504521
platform.link._flush_release_queue()
505522
import time
506523
time.sleep(0.1)
507524

508-
yield WebGPUTestEnv(page=test_page, wj=wj, platform=platform)
525+
try:
526+
yield WebGPUTestEnv(page=test_page, wj=wj, platform=platform)
527+
finally:
528+
test_page.close()
529+
http_server.shutdown()
530+
shutil.rmtree(tmpdir, ignore_errors=True)
509531

510-
test_page.close()
511-
http_server.shutdown()
512-
shutil.rmtree(tmpdir, ignore_errors=True)
532+
533+
@contextmanager
534+
def headless_session(width=600, height=600):
535+
"""Standalone headless WebGPU environment, usable **without pytest**.
536+
537+
Launches its own Playwright + Chromium, brings up the WS bridge and device,
538+
and yields a :class:`WebGPUTestEnv` with a canvas ready. Lets render scripts
539+
live anywhere (not just under the package's test tree)::
540+
541+
from webgpu.testing import headless_session
542+
from ngsolve_webgpu.jupyter import Draw
543+
544+
with headless_session(1500, 1100) as env:
545+
scene = Draw(geo, width=1500, height=1100)
546+
env.readback_texture(scene, "out.png")
547+
"""
548+
with sync_playwright() as pw:
549+
browser = launch_webgpu_browser(pw)
550+
try:
551+
with _open_env(browser) as env:
552+
if width and height:
553+
env.ensure_canvas(width, height)
554+
yield env
555+
finally:
556+
browser.close()
557+
558+
559+
@pytest.fixture(scope="session")
560+
def webgpu_env(browser):
561+
"""Full webgpu environment: platform + device + browser page with canvas."""
562+
with _open_env(browser) as env:
563+
yield env

0 commit comments

Comments
 (0)