@@ -126,6 +126,163 @@ def _DrawHTML(scene, width=640, height=600):
126126 return scene
127127
128128
129+ def _DrawHTMLLazy (scene , width = 640 , height = 600 ):
130+ """Export scene as a lazy-loading HTML snippet with a pre-baked screenshot.
131+
132+ The screenshot is rendered in a separate subprocess (own Chrome + GPU device)
133+ so it doesn't interfere with the main build process's mapAsync calls.
134+ The scene blob and engine JS are saved as separate static files and only
135+ fetched when the user clicks to interact.
136+ """
137+ global _engine_emitted
138+ from IPython .display import HTML , Javascript , display
139+
140+ from .engine import engine_js
141+
142+ if isinstance (scene , Renderer ):
143+ scene = [scene ]
144+ if isinstance (scene , list ):
145+ scene = Scene (scene )
146+
147+ id_ = f"__webgpu_{ next (_id_counter )} _"
148+ canvas_id = f"{ id_ } canvas"
149+
150+ # Create a canvas in the headless Chrome DOM for GPU initialization.
151+ js = platform .js
152+ html_canvas = js .document .createElement ("canvas" )
153+ html_canvas .width = width
154+ html_canvas .height = height
155+ html_canvas .id = canvas_id
156+ js .document .body .appendChild (html_canvas )
157+
158+ canvas = Canvas (init_device_sync (), html_canvas )
159+ scene .init (canvas )
160+
161+ # Disable render() — patchedRequestAnimationFrame hangs in headless Chrome.
162+ scene .render = lambda * a , ** kw : None
163+ scene ._on_camera_changed = lambda * a , ** kw : None
164+
165+ # Export scene to blob (scene.init already filled all GPU buffers)
166+ blob = scene .export ()
167+ blob_b64 = base64 .b64encode (blob ).decode ()
168+
169+ # Save blob and engine JS as static files for deferred loading.
170+ # Use a unique hash to avoid collisions across notebooks.
171+ import hashlib
172+ blob_hash = hashlib .md5 (blob ).hexdigest ()[:10 ]
173+ static_dir = _get_static_dir ()
174+
175+ # Save blob as a JS file (script src works on file:// unlike fetch)
176+ scene_filename = f"scene_{ blob_hash } .js"
177+ scene_var = f"__webgpu_blob_{ blob_hash } "
178+ (static_dir / scene_filename ).write_text (
179+ f"window.{ scene_var } = \" { blob_b64 } \" ;"
180+ )
181+ scene_url = f"_static/webgpu_scenes/{ scene_filename } "
182+
183+ if not _engine_emitted :
184+ (static_dir / "engine.js" ).write_text (engine_js )
185+ _engine_emitted = True
186+ engine_url = "_static/webgpu_scenes/engine.js"
187+
188+ # Capture screenshot in a separate subprocess
189+ screenshot_b64 = _capture_screenshot_subprocess (blob_b64 , width , height )
190+ screenshot_filename = f"screenshot_{ blob_hash } .png"
191+ if screenshot_b64 :
192+ (static_dir / screenshot_filename ).write_bytes (base64 .b64decode (screenshot_b64 ))
193+ screenshot_url = f"_static/webgpu_scenes/{ screenshot_filename } "
194+
195+ # Emit the lazy-load HTML: only screenshot + overlay, everything else loaded on click
196+ lazy_html = f"""
197+ <div id='{ id_ } root'
198+ style="position: relative; width: { width } px; max-width: 100%; overflow: hidden;"
199+ >
200+ <img id='{ id_ } img'
201+ src='{ screenshot_url } '
202+ style='width: { width } px; height: { height } px; max-width: 100%; display: block;'
203+ />
204+ <div id='{ id_ } overlay'
205+ style='position: absolute; top: 0; left: 0; width: 100%; height: 100%;
206+ display: flex; align-items: center; justify-content: center;
207+ background: rgba(0,0,0,0); cursor: pointer; transition: background 0.2s;'
208+ onmouseover="this.style.background='rgba(0,0,0,0.18)'; this.querySelector('span').style.opacity='1'"
209+ onmouseout="this.style.background='rgba(0,0,0,0)'; this.querySelector('span').style.opacity='0'"
210+ onclick="(function() {{ var r = document.getElementById('{ id_ } root'); if (r.__activated) return; r.__activated = true; function activate() {{ document.getElementById('{ id_ } img').style.display = 'none'; document.getElementById('{ id_ } overlay').style.display = 'none'; document.getElementById('{ canvas_id } ').style.display = 'block'; RenderEngine.create('{ canvas_id } ', window.{ scene_var } ); }} function loadBlob() {{ var s = document.createElement('script'); s.src = '{ scene_url } '; s.onload = activate; document.head.appendChild(s); }} if (typeof RenderEngine === 'undefined') {{ var s = document.createElement('script'); s.src = '{ engine_url } '; s.onload = loadBlob; document.head.appendChild(s); }} else {{ loadBlob(); }} }})()"
211+ >
212+ <span style='color: white; font-size: 1.3em; font-weight: bold;
213+ text-shadow: 0 1px 4px rgba(0,0,0,0.7); pointer-events: none;
214+ opacity: 0; transition: opacity 0.2s;'
215+ >▶ Click to interact</span>
216+ </div>
217+ <canvas
218+ id='{ canvas_id } '
219+ style='background-color: white; width: { width } px; height: { height } px;
220+ touch-action: none; max-width: 100%; display: none;'
221+ ></canvas>
222+ <div id='{ id_ } lilgui'
223+ style='position: absolute; top: 0; right: 0; z-index: 10;'
224+ ></div>
225+ </div>
226+ """
227+
228+ display (HTML (lazy_html ))
229+ return scene
230+
231+
232+ def _get_static_dir ():
233+ """Get or create the static directory for webgpu scene assets."""
234+ from pathlib import Path
235+ # Try to find the docs source _static directory
236+ for candidate in [
237+ Path ("docs/_static/webgpu_scenes" ),
238+ Path ("_static/webgpu_scenes" ),
239+ Path ("webgpu_scenes" ),
240+ ]:
241+ # Check if the parent _static dir exists (we're in a docs build)
242+ if candidate .parent .exists ():
243+ candidate .mkdir (parents = True , exist_ok = True )
244+ return candidate
245+ # Fallback: create in current directory
246+ fallback = Path ("_static/webgpu_scenes" )
247+ fallback .mkdir (parents = True , exist_ok = True )
248+ return fallback
249+
250+
251+ # Persistent screenshot worker process (launched once, handles all screenshots)
252+ _screenshot_worker = None
253+
254+
255+ def _capture_screenshot_subprocess (blob_b64 , width , height ):
256+ """Send a screenshot request to the persistent worker process."""
257+ import subprocess
258+ import sys
259+
260+ global _screenshot_worker
261+ if _screenshot_worker is None or _screenshot_worker .poll () is not None :
262+ _screenshot_worker = subprocess .Popen (
263+ [sys .executable , "-m" , "webgpu.export.screenshot" ],
264+ stdin = subprocess .PIPE ,
265+ stdout = subprocess .PIPE ,
266+ stderr = subprocess .PIPE ,
267+ text = True ,
268+ )
269+ # Wait for READY signal
270+ ready = _screenshot_worker .stdout .readline ().strip ()
271+ if ready != "READY" :
272+ print (f"Warning: screenshot worker failed to start: { ready } " )
273+ _screenshot_worker = None
274+ return ""
275+
276+ try :
277+ _screenshot_worker .stdin .write (f"{ width } { height } { blob_b64 } \n " )
278+ _screenshot_worker .stdin .flush ()
279+ result = _screenshot_worker .stdout .readline ().strip ()
280+ return result
281+ except Exception as e :
282+ print (f"Warning: screenshot capture failed: { e } " )
283+ return ""
284+
285+
129286def _init_export_gpu ():
130287 """Start headless Chrome with WebGPU for buffer export during nbconvert.
131288
@@ -265,7 +422,11 @@ def Draw(
265422 if is_exporting :
266423 # Launch headless Chrome for GPU access during export
267424 _init_export_gpu ()
268- Draw = _DrawHTML
425+ is_lazy_load = "WEBGPU_LAZY_LOAD" in os .environ
426+ if is_lazy_load :
427+ Draw = _DrawHTMLLazy
428+ else :
429+ Draw = _DrawHTML
269430 else :
270431 # Not exporting and not running in pyodide -> Start a websocket server
271432 # and wait for the client to connect.
0 commit comments