VibecoderMcSwaggins commited on
Commit
08c3363
·
unverified ·
1 Parent(s): 1973147

fix(ui): use head_paths for NiiVue loading on HF Spaces (#24) (#25)

Browse files

* chore: DIAGNOSTIC - disable js_on_load to test HF Spaces loading

* fix(ui): use head_paths for NiiVue loading on HF Spaces (#24)

Root cause: gr.HTML(js_on_load=...) with dynamic ES module import()
breaks Gradio frontend initialization on HuggingFace Spaces, causing
the app to hang on "Loading..." indefinitely.

Fix: Use the official Gradio-recommended head_paths approach:
1. Generate niivue-loader.html at runtime with correct absolute path
2. Load via demo.launch(head_paths=[...]) instead of head=
3. NiiVue exposed globally as window.Niivue
4. js_on_load and event handlers access window.Niivue (no import())

This follows the solution recommended by Gradio maintainers in
GitHub issue #11649 for loading custom JavaScript reliably.

Files changed:
- viewer.py: Add get_niivue_loader_path(), convert JS to regular strings
- app.py: Use head_paths instead of head parameter
- ui/app.py: Use head_paths, re-enable NIIVUE_UPDATE_JS handler
- components.py: Re-enable js_on_load=NIIVUE_ON_LOAD_JS
- assets/niivue-loader.html: Auto-generated loader (gitignored runtime)

All 136 tests pass. Lint and type checks clean.

* fix: gitignore niivue-loader.html (generated at runtime with env-specific path)

* fix: add logging and error handling to get_niivue_loader_path (CodeRabbit)

.gitignore CHANGED
@@ -215,3 +215,5 @@ data/scratch/
215
 
216
  # macOS
217
  .DS_Store
 
 
 
215
 
216
  # macOS
217
  .DS_Store
218
+ # Auto-generated at runtime (path is environment-specific)
219
+ src/stroke_deepisles_demo/ui/assets/niivue-loader.html
app.py CHANGED
@@ -16,6 +16,7 @@ import gradio as gr
16
  from stroke_deepisles_demo.core.config import get_settings
17
  from stroke_deepisles_demo.core.logging import setup_logging
18
  from stroke_deepisles_demo.ui.app import get_demo
 
19
 
20
  # Initialize logging
21
  settings = get_settings()
@@ -35,6 +36,9 @@ if __name__ == "__main__":
35
  # Assets are located in src/stroke_deepisles_demo/ui/assets
36
  assets_dir = Path(__file__).parent / "src" / "stroke_deepisles_demo" / "ui" / "assets"
37
 
 
 
 
38
  demo.launch(
39
  server_name=settings.gradio_server_name,
40
  server_port=settings.gradio_server_port,
@@ -42,4 +46,5 @@ if __name__ == "__main__":
42
  theme=gr.themes.Soft(),
43
  css="footer {visibility: hidden}",
44
  allowed_paths=[str(assets_dir)],
 
45
  )
 
16
  from stroke_deepisles_demo.core.config import get_settings
17
  from stroke_deepisles_demo.core.logging import setup_logging
18
  from stroke_deepisles_demo.ui.app import get_demo
19
+ from stroke_deepisles_demo.ui.viewer import get_niivue_loader_path
20
 
21
  # Initialize logging
22
  settings = get_settings()
 
36
  # Assets are located in src/stroke_deepisles_demo/ui/assets
37
  assets_dir = Path(__file__).parent / "src" / "stroke_deepisles_demo" / "ui" / "assets"
38
 
39
+ # Generate the NiiVue loader HTML file (creates if needed)
40
+ niivue_loader = get_niivue_loader_path()
41
+
42
  demo.launch(
43
  server_name=settings.gradio_server_name,
44
  server_port=settings.gradio_server_port,
 
46
  theme=gr.themes.Soft(),
47
  css="footer {visibility: hidden}",
48
  allowed_paths=[str(assets_dir)],
49
+ head_paths=[str(niivue_loader)], # Official Gradio approach (Issue #11649)
50
  )
docs/specs/24-bug-hf-spaces-loading-forever.md CHANGED
@@ -203,3 +203,52 @@ This caused the Gradio frontend to remain stuck on "Loading..." even though the
203
  3. **Security-compliant:** Respects HF Spaces CSP policy
204
  4. **Reproducible:** Same NiiVue version always loaded
205
  5. **Standard practice:** Vendoring is the recommended approach for HF Spaces
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  3. **Security-compliant:** Respects HF Spaces CSP policy
204
  4. **Reproducible:** Same NiiVue version always loaded
205
  5. **Standard practice:** Vendoring is the recommended approach for HF Spaces
206
+
207
+ ---
208
+
209
+ ## Update: Vendoring Alone Did Not Fix It (2025-12-09)
210
+
211
+ ### New Finding
212
+
213
+ Vendoring NiiVue locally bypassed CSP but **the app still wouldn't load**.
214
+
215
+ **Diagnostic test:** Disabled `js_on_load` parameter entirely.
216
+
217
+ **Result:** App loads perfectly! Everything works EXCEPT Interactive 3D viewer.
218
+
219
+ ### Real Root Cause
220
+
221
+ **`gr.HTML(js_on_load=...)` with dynamic ES module `import()` blocks Gradio frontend initialization on HF Spaces.**
222
+
223
+ The issue is NOT the vendored file location - it's HOW we load the JavaScript:
224
+
225
+ ```javascript
226
+ // This approach BREAKS the entire Gradio app on HF Spaces:
227
+ const { Niivue } = await import('/gradio_api/file=...');
228
+ ```
229
+
230
+ When this fails (silently), it prevents the Gradio frontend from completing initialization, causing the eternal "Loading..." screen.
231
+
232
+ ### Evidence
233
+
234
+ With `js_on_load` disabled:
235
+ - ✅ Gradio app loads
236
+ - ✅ Case selector works
237
+ - ✅ DeepISLES segmentation runs (38.66s)
238
+ - ✅ Static Report (Matplotlib) renders correctly
239
+ - ✅ Metrics JSON displays
240
+ - ✅ Download works
241
+ - ❌ Interactive 3D shows "Loading viewer..." (expected - JS disabled)
242
+
243
+ ### Correct Approach
244
+
245
+ Use `gr.Blocks(head=...)` to load NiiVue as a `<script>` tag instead of dynamic `import()`:
246
+
247
+ ```python
248
+ with gr.Blocks(
249
+ head='<script src="/gradio_api/file=.../niivue.js"></script>'
250
+ ) as demo:
251
+ ...
252
+ ```
253
+
254
+ Or use the global `js` parameter on `gr.Blocks` to define initialization code that runs after the script loads.
src/stroke_deepisles_demo/ui/app.py CHANGED
@@ -21,6 +21,7 @@ from stroke_deepisles_demo.ui.components import (
21
  from stroke_deepisles_demo.ui.viewer import (
22
  NIIVUE_UPDATE_JS,
23
  create_niivue_html,
 
24
  nifti_to_gradio_url,
25
  render_3panel_view,
26
  render_slice_comparison,
@@ -245,7 +246,7 @@ def create_app() -> gr.Blocks:
245
  previous_results_state, # Update state with new results_dir
246
  ],
247
  ).then(
248
- fn=None, # Explicitly None to run JS only
249
  js=NIIVUE_UPDATE_JS,
250
  )
251
 
@@ -277,6 +278,9 @@ if __name__ == "__main__":
277
  # Allow access to local assets (e.g., niivue.js)
278
  assets_dir = Path(__file__).parent / "assets"
279
 
 
 
 
280
  get_demo().launch(
281
  server_name=settings.gradio_server_name,
282
  server_port=settings.gradio_server_port,
@@ -285,4 +289,5 @@ if __name__ == "__main__":
285
  css="footer {visibility: hidden}",
286
  show_error=True, # Show full Python tracebacks in UI for debugging
287
  allowed_paths=[str(assets_dir)],
 
288
  )
 
21
  from stroke_deepisles_demo.ui.viewer import (
22
  NIIVUE_UPDATE_JS,
23
  create_niivue_html,
24
+ get_niivue_loader_path,
25
  nifti_to_gradio_url,
26
  render_3panel_view,
27
  render_slice_comparison,
 
246
  previous_results_state, # Update state with new results_dir
247
  ],
248
  ).then(
249
+ fn=None, # JS-only handler to re-initialize NiiVue after HTML update
250
  js=NIIVUE_UPDATE_JS,
251
  )
252
 
 
278
  # Allow access to local assets (e.g., niivue.js)
279
  assets_dir = Path(__file__).parent / "assets"
280
 
281
+ # Generate the NiiVue loader HTML file (creates if needed)
282
+ niivue_loader = get_niivue_loader_path()
283
+
284
  get_demo().launch(
285
  server_name=settings.gradio_server_name,
286
  server_port=settings.gradio_server_port,
 
289
  css="footer {visibility: hidden}",
290
  show_error=True, # Show full Python tracebacks in UI for debugging
291
  allowed_paths=[str(assets_dir)],
292
+ head_paths=[str(niivue_loader)], # Official Gradio approach (Issue #11649)
293
  )
src/stroke_deepisles_demo/ui/components.py CHANGED
@@ -41,10 +41,9 @@ def create_results_display() -> dict[str, gr.components.Component]:
41
  with gr.Group():
42
  with gr.Tabs():
43
  with gr.Tab("Interactive 3D"):
44
- # NiiVue visualization uses HTML with js_on_load for JavaScript execution
45
- # Note: Gradio strips <script> tags from HTML value for security,
46
- # so we must use js_on_load to run our NiiVue initialization code.
47
- # The HTML value contains data-* attributes with volume URLs.
48
  niivue_viewer = gr.HTML(
49
  label="Interactive 3D Viewer",
50
  js_on_load=NIIVUE_ON_LOAD_JS,
 
41
  with gr.Group():
42
  with gr.Tabs():
43
  with gr.Tab("Interactive 3D"):
44
+ # NiiVue 3D viewer - uses js_on_load to initialize after HTML renders
45
+ # The NiiVue library is loaded globally via head_paths (see app.py)
46
+ # This handler accesses window.Niivue set by the loader script
 
47
  niivue_viewer = gr.HTML(
48
  label="Interactive 3D Viewer",
49
  js_on_load=NIIVUE_ON_LOAD_JS,
src/stroke_deepisles_demo/ui/viewer.py CHANGED
@@ -19,8 +19,11 @@ from pathlib import Path
19
  import numpy as np
20
  from matplotlib.figure import Figure
21
 
 
22
  from stroke_deepisles_demo.metrics import load_nifti_as_array
23
 
 
 
24
  # NiiVue version - updated to latest stable (Dec 2025)
25
  # Switched to local vendoring to avoid CSP issues on HuggingFace Spaces (Issue #24)
26
  # The file is located in src/stroke_deepisles_demo/ui/assets/niivue.js
@@ -33,6 +36,79 @@ _NIIVUE_JS_PATH = _ASSET_DIR / "niivue.js"
33
  NIIVUE_JS_URL = f"/gradio_api/file={_NIIVUE_JS_PATH.resolve()}"
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def nifti_to_gradio_url(nifti_path: Path) -> str:
37
  """
38
  Get Gradio file URL for a NIfTI file.
@@ -358,8 +434,12 @@ def create_niivue_html(
358
  # JavaScript code for js_on_load parameter
359
  # This runs when the gr.HTML component FIRST loads (mounts)
360
  # Variables available: element, props, trigger
361
- NIIVUE_ON_LOAD_JS = f"""
362
- (async () => {{
 
 
 
 
363
  const container = element.querySelector('.niivue-viewer') || element;
364
  const canvas = element.querySelector('canvas');
365
  const status = element.querySelector('.niivue-status');
@@ -369,34 +449,38 @@ NIIVUE_ON_LOAD_JS = f"""
369
  const maskUrl = container.dataset.maskUrl;
370
 
371
  // Skip if no volume URL (initial empty state)
372
- if (!volumeUrl) {{
373
  if (status) status.innerText = 'Waiting for segmentation...';
374
  return;
375
- }}
376
 
377
- try {{
378
  if (status) status.innerText = 'Checking WebGL2...';
379
 
380
  // Check WebGL2 support
381
  const gl = canvas.getContext('webgl2');
382
- if (!gl) {{
383
  container.innerHTML = '<div style="color:#fff;padding:20px;text-align:center;">WebGL2 not supported. Please use a modern browser.</div>';
384
  return;
385
- }}
386
 
387
  if (status) status.innerText = 'Loading NiiVue...';
388
 
389
- // Dynamically import NiiVue from local asset (vendored)
390
- const {{ Niivue }} = await import('{NIIVUE_JS_URL}');
 
 
 
 
391
 
392
  // Initialize NiiVue
393
- const nv = new Niivue({{
394
  logging: false,
395
  show3Dcrosshair: true,
396
  textHeight: 0.04,
397
  backColor: [0, 0, 0, 1],
398
  crosshairColor: [0.2, 0.8, 0.2, 1]
399
- }});
400
 
401
  // Attach to canvas
402
  await nv.attachToCanvas(canvas);
@@ -405,31 +489,31 @@ NIIVUE_ON_LOAD_JS = f"""
405
  if (status) status.style.display = 'none';
406
 
407
  // Prepare volumes
408
- const volumes = [{{ url: volumeUrl, name: 'input.nii.gz' }}];
409
 
410
- if (maskUrl) {{
411
- volumes.push({{
412
  url: maskUrl,
413
  colorMap: 'red',
414
  opacity: 0.5
415
- }});
416
- }}
417
 
418
  // Load volumes
419
  await nv.loadVolumes(volumes);
420
 
421
  // Configure view: multiplanar + 3D
422
  nv.setSliceType(nv.sliceTypeMultiplanar);
423
- if (typeof nv.setMultiplanarLayout === 'function') {{
424
  nv.setMultiplanarLayout(2);
425
- }}
426
  nv.opts.show3Dcrosshair = true;
427
  nv.setRenderAzimuthElevation(120, 10);
428
  nv.drawScene();
429
 
430
  console.log('NiiVue viewer initialized successfully');
431
 
432
- }} catch (error) {{
433
  console.error('NiiVue initialization error:', error);
434
  // Use textContent instead of innerHTML to prevent XSS
435
  const errorDiv = document.createElement('div');
@@ -437,22 +521,26 @@ NIIVUE_ON_LOAD_JS = f"""
437
  errorDiv.textContent = 'Error loading viewer: ' + error.message;
438
  container.innerHTML = '';
439
  container.appendChild(errorDiv);
440
- }}
441
- }})();
442
  """
443
 
444
  # JavaScript code for event handlers (e.g. .then(js=...))
445
  # This runs after Python updates the HTML value.
446
  # ⚠️ CRITICAL: 'element' is NOT available here! Must use document.querySelector
447
- NIIVUE_UPDATE_JS = f"""
448
- (async () => {{
 
 
 
 
449
  // We must find the container globally since 'element' is not available in event handlers
450
  const container = document.querySelector('.niivue-viewer');
451
 
452
- if (!container) {{
453
  console.error('NiiVue container not found');
454
  return;
455
- }}
456
 
457
  const canvas = container.querySelector('canvas');
458
  const status = container.querySelector('.niivue-status');
@@ -462,31 +550,35 @@ NIIVUE_UPDATE_JS = f"""
462
  const maskUrl = container.dataset.maskUrl;
463
 
464
  // Skip if no volume URL
465
- if (!volumeUrl) {{
466
  return;
467
- }}
468
 
469
- try {{
470
  if (status) status.innerText = 'Reloading NiiVue...';
471
 
472
  // Check WebGL2 support
473
  const gl = canvas.getContext('webgl2');
474
- if (!gl) {{
475
  container.innerHTML = '<div style="color:#fff;padding:20px;text-align:center;">WebGL2 not supported. Please use a modern browser.</div>';
476
  return;
477
- }}
478
 
479
- // Dynamically import NiiVue from local asset (vendored)
480
- const {{ Niivue }} = await import('{NIIVUE_JS_URL}');
 
 
 
 
481
 
482
  // Initialize NiiVue
483
- const nv = new Niivue({{
484
  logging: false,
485
  show3Dcrosshair: true,
486
  textHeight: 0.04,
487
  backColor: [0, 0, 0, 1],
488
  crosshairColor: [0.2, 0.8, 0.2, 1]
489
- }});
490
 
491
  // Attach to canvas
492
  await nv.attachToCanvas(canvas);
@@ -495,39 +587,39 @@ NIIVUE_UPDATE_JS = f"""
495
  if (status) status.style.display = 'none';
496
 
497
  // Prepare volumes
498
- const volumes = [{{ url: volumeUrl, name: 'input.nii.gz' }}];
499
 
500
- if (maskUrl) {{
501
- volumes.push({{
502
  url: maskUrl,
503
  colorMap: 'red',
504
  opacity: 0.5
505
- }});
506
- }}
507
 
508
  // Load volumes
509
  await nv.loadVolumes(volumes);
510
 
511
  // Configure view: multiplanar + 3D
512
  nv.setSliceType(nv.sliceTypeMultiplanar);
513
- if (typeof nv.setMultiplanarLayout === 'function') {{
514
  nv.setMultiplanarLayout(2);
515
- }}
516
  nv.opts.show3Dcrosshair = true;
517
  nv.setRenderAzimuthElevation(120, 10);
518
  nv.drawScene();
519
 
520
  console.log('NiiVue viewer re-initialized successfully via event handler');
521
 
522
- }} catch (error) {{
523
  console.error('NiiVue re-initialization error:', error);
524
  const errorDiv = document.createElement('div');
525
  errorDiv.style.cssText = 'color:#f66;padding:20px;text-align:center;';
526
  errorDiv.textContent = 'Error reloading viewer: ' + error.message;
527
- if (container) {{
528
  container.innerHTML = '';
529
  container.appendChild(errorDiv);
530
- }}
531
- }}
532
- }})();
533
  """
 
19
  import numpy as np
20
  from matplotlib.figure import Figure
21
 
22
+ from stroke_deepisles_demo.core.logging import get_logger
23
  from stroke_deepisles_demo.metrics import load_nifti_as_array
24
 
25
+ logger = get_logger(__name__)
26
+
27
  # NiiVue version - updated to latest stable (Dec 2025)
28
  # Switched to local vendoring to avoid CSP issues on HuggingFace Spaces (Issue #24)
29
  # The file is located in src/stroke_deepisles_demo/ui/assets/niivue.js
 
36
  NIIVUE_JS_URL = f"/gradio_api/file={_NIIVUE_JS_PATH.resolve()}"
37
 
38
 
39
+ def get_niivue_loader_path() -> Path:
40
+ """
41
+ Get path to the NiiVue loader HTML file, creating it if needed.
42
+
43
+ This function generates an HTML file that loads NiiVue as a global.
44
+ Using head_paths with a file is the official Gradio-recommended approach
45
+ for loading custom JavaScript (see GitHub issue #11649).
46
+
47
+ The HTML file is generated at runtime because the niivue.js path
48
+ is dynamic (depends on installation location).
49
+
50
+ Returns:
51
+ Path to the niivue-loader.html file
52
+
53
+ Note:
54
+ The returned path must be included in allowed_paths during launch().
55
+ """
56
+ loader_path = _ASSET_DIR / "niivue-loader.html"
57
+
58
+ # Generate the loader HTML with the correct absolute path
59
+ loader_content = f"""<!--
60
+ NiiVue Loader for Gradio (auto-generated)
61
+ Loads NiiVue library and exposes it globally for js_on_load handlers.
62
+ See: docs/specs/24-bug-hf-spaces-loading-forever.md
63
+ -->
64
+ <script type="module">
65
+ import {{ Niivue }} from '{NIIVUE_JS_URL}';
66
+ window.Niivue = Niivue;
67
+ console.log('[NiiVue Loader] Loaded globally:', typeof window.Niivue);
68
+ </script>
69
+ """
70
+
71
+ # Write/update the loader file (idempotent)
72
+ # This ensures the path is always correct for the current installation
73
+ try:
74
+ # Check if file exists and has correct content
75
+ if loader_path.exists():
76
+ existing_content = loader_path.read_text()
77
+ if existing_content == loader_content:
78
+ return loader_path
79
+
80
+ loader_path.write_text(loader_content)
81
+ logger.debug("Generated NiiVue loader at %s", loader_path)
82
+ except OSError as e:
83
+ # If we can't write (e.g., read-only filesystem), the file should
84
+ # already exist from the build process
85
+ logger.warning("Could not write loader file at %s: %s", loader_path, e)
86
+ if not loader_path.exists():
87
+ raise RuntimeError(
88
+ f"NiiVue loader file not found and cannot be created: {loader_path}"
89
+ ) from e
90
+
91
+ return loader_path
92
+
93
+
94
+ # Legacy function for backward compatibility
95
+ def get_niivue_head_script() -> str:
96
+ """
97
+ Get HTML script tag for loading NiiVue in Gradio's head.
98
+
99
+ DEPRECATED: Use get_niivue_loader_path() with head_paths instead.
100
+ This function is kept for backward compatibility only.
101
+
102
+ Returns:
103
+ HTML string with script tag
104
+ """
105
+ return f"""<script type="module">
106
+ import {{ Niivue }} from '{NIIVUE_JS_URL}';
107
+ window.Niivue = Niivue;
108
+ console.log('NiiVue loaded globally:', typeof window.Niivue);
109
+ </script>"""
110
+
111
+
112
  def nifti_to_gradio_url(nifti_path: Path) -> str:
113
  """
114
  Get Gradio file URL for a NIfTI file.
 
434
  # JavaScript code for js_on_load parameter
435
  # This runs when the gr.HTML component FIRST loads (mounts)
436
  # Variables available: element, props, trigger
437
+ #
438
+ # IMPORTANT: This code uses window.Niivue which must be loaded via
439
+ # gr.Blocks(head=get_niivue_head_script()). Do NOT use dynamic import()
440
+ # as it breaks Gradio on HF Spaces.
441
+ NIIVUE_ON_LOAD_JS = """
442
+ (async () => {
443
  const container = element.querySelector('.niivue-viewer') || element;
444
  const canvas = element.querySelector('canvas');
445
  const status = element.querySelector('.niivue-status');
 
449
  const maskUrl = container.dataset.maskUrl;
450
 
451
  // Skip if no volume URL (initial empty state)
452
+ if (!volumeUrl) {
453
  if (status) status.innerText = 'Waiting for segmentation...';
454
  return;
455
+ }
456
 
457
+ try {
458
  if (status) status.innerText = 'Checking WebGL2...';
459
 
460
  // Check WebGL2 support
461
  const gl = canvas.getContext('webgl2');
462
+ if (!gl) {
463
  container.innerHTML = '<div style="color:#fff;padding:20px;text-align:center;">WebGL2 not supported. Please use a modern browser.</div>';
464
  return;
465
+ }
466
 
467
  if (status) status.innerText = 'Loading NiiVue...';
468
 
469
+ // Use globally loaded NiiVue (from head script)
470
+ // Do NOT use dynamic import() - it breaks Gradio on HF Spaces
471
+ const Niivue = window.Niivue;
472
+ if (!Niivue) {
473
+ throw new Error('NiiVue not loaded. Ensure head script is included via gr.Blocks(head=...)');
474
+ }
475
 
476
  // Initialize NiiVue
477
+ const nv = new Niivue({
478
  logging: false,
479
  show3Dcrosshair: true,
480
  textHeight: 0.04,
481
  backColor: [0, 0, 0, 1],
482
  crosshairColor: [0.2, 0.8, 0.2, 1]
483
+ });
484
 
485
  // Attach to canvas
486
  await nv.attachToCanvas(canvas);
 
489
  if (status) status.style.display = 'none';
490
 
491
  // Prepare volumes
492
+ const volumes = [{ url: volumeUrl, name: 'input.nii.gz' }];
493
 
494
+ if (maskUrl) {
495
+ volumes.push({
496
  url: maskUrl,
497
  colorMap: 'red',
498
  opacity: 0.5
499
+ });
500
+ }
501
 
502
  // Load volumes
503
  await nv.loadVolumes(volumes);
504
 
505
  // Configure view: multiplanar + 3D
506
  nv.setSliceType(nv.sliceTypeMultiplanar);
507
+ if (typeof nv.setMultiplanarLayout === 'function') {
508
  nv.setMultiplanarLayout(2);
509
+ }
510
  nv.opts.show3Dcrosshair = true;
511
  nv.setRenderAzimuthElevation(120, 10);
512
  nv.drawScene();
513
 
514
  console.log('NiiVue viewer initialized successfully');
515
 
516
+ } catch (error) {
517
  console.error('NiiVue initialization error:', error);
518
  // Use textContent instead of innerHTML to prevent XSS
519
  const errorDiv = document.createElement('div');
 
521
  errorDiv.textContent = 'Error loading viewer: ' + error.message;
522
  container.innerHTML = '';
523
  container.appendChild(errorDiv);
524
+ }
525
+ })();
526
  """
527
 
528
  # JavaScript code for event handlers (e.g. .then(js=...))
529
  # This runs after Python updates the HTML value.
530
  # ⚠️ CRITICAL: 'element' is NOT available here! Must use document.querySelector
531
+ #
532
+ # IMPORTANT: This code uses window.Niivue which must be loaded via
533
+ # head_paths with niivue-loader.html. Do NOT use dynamic import()
534
+ # as it breaks Gradio on HF Spaces.
535
+ NIIVUE_UPDATE_JS = """
536
+ (async () => {
537
  // We must find the container globally since 'element' is not available in event handlers
538
  const container = document.querySelector('.niivue-viewer');
539
 
540
+ if (!container) {
541
  console.error('NiiVue container not found');
542
  return;
543
+ }
544
 
545
  const canvas = container.querySelector('canvas');
546
  const status = container.querySelector('.niivue-status');
 
550
  const maskUrl = container.dataset.maskUrl;
551
 
552
  // Skip if no volume URL
553
+ if (!volumeUrl) {
554
  return;
555
+ }
556
 
557
+ try {
558
  if (status) status.innerText = 'Reloading NiiVue...';
559
 
560
  // Check WebGL2 support
561
  const gl = canvas.getContext('webgl2');
562
+ if (!gl) {
563
  container.innerHTML = '<div style="color:#fff;padding:20px;text-align:center;">WebGL2 not supported. Please use a modern browser.</div>';
564
  return;
565
+ }
566
 
567
+ // Use globally loaded NiiVue (from head script)
568
+ // Do NOT use dynamic import() - it breaks Gradio on HF Spaces
569
+ const Niivue = window.Niivue;
570
+ if (!Niivue) {
571
+ throw new Error('NiiVue not loaded. Ensure head_paths includes niivue-loader.html');
572
+ }
573
 
574
  // Initialize NiiVue
575
+ const nv = new Niivue({
576
  logging: false,
577
  show3Dcrosshair: true,
578
  textHeight: 0.04,
579
  backColor: [0, 0, 0, 1],
580
  crosshairColor: [0.2, 0.8, 0.2, 1]
581
+ });
582
 
583
  // Attach to canvas
584
  await nv.attachToCanvas(canvas);
 
587
  if (status) status.style.display = 'none';
588
 
589
  // Prepare volumes
590
+ const volumes = [{ url: volumeUrl, name: 'input.nii.gz' }];
591
 
592
+ if (maskUrl) {
593
+ volumes.push({
594
  url: maskUrl,
595
  colorMap: 'red',
596
  opacity: 0.5
597
+ });
598
+ }
599
 
600
  // Load volumes
601
  await nv.loadVolumes(volumes);
602
 
603
  // Configure view: multiplanar + 3D
604
  nv.setSliceType(nv.sliceTypeMultiplanar);
605
+ if (typeof nv.setMultiplanarLayout === 'function') {
606
  nv.setMultiplanarLayout(2);
607
+ }
608
  nv.opts.show3Dcrosshair = true;
609
  nv.setRenderAzimuthElevation(120, 10);
610
  nv.drawScene();
611
 
612
  console.log('NiiVue viewer re-initialized successfully via event handler');
613
 
614
+ } catch (error) {
615
  console.error('NiiVue re-initialization error:', error);
616
  const errorDiv = document.createElement('div');
617
  errorDiv.style.cssText = 'color:#f66;padding:20px;text-align:center;';
618
  errorDiv.textContent = 'Error reloading viewer: ' + error.message;
619
+ if (container) {
620
  container.innerHTML = '';
621
  container.appendChild(errorDiv);
622
+ }
623
+ }
624
+ })();
625
  """