WIP: Add Jupyter notebook widget for WebGL viewer#608
WIP: Add Jupyter notebook widget for WebGL viewer#608mvdoc wants to merge 15 commits intogallantlab:mainfrom
Conversation
Implements two display methods for embedding pycortex WebGL brain viewers in Jupyter notebooks: - display_iframe: Embeds the Tornado-served viewer in an IFrame for full interactivity (surface morphing, data switching, WebSocket) - display_static: Generates self-contained HTML served via a lightweight HTTP server, suitable for sharing https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
Covers IFrame mode, static mode, programmatic control via JSMixer, viewer customization options, and raw HTML generation. https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
Sphinx-gallery only processes files matching the /plot_ pattern. Renamed jupyter_notebook.py -> plot_jupyter_notebook.py and added sphinx_gallery_dummy_images directive since this example cannot execute during doc builds (requires Jupyter + browser). https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
- Add nbsphinx extension to Sphinx conf with execute='never' - Exclude auto_examples/*.ipynb to avoid conflict with sphinx-gallery - Replace plot_jupyter_notebook.py with jupyter_notebook.ipynb - Symlink notebook from examples/webgl/ into docs/notebooks/ - Add "Jupyter Notebooks" section to docs index toctree https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
Restructured the notebook so the static viewer uses make_notebook_html() + IPython.display.HTML with an srcdoc IFrame, which persists the full 3D viewer in the cell output. The IFrame mode and other server-dependent examples are shown as markdown code blocks since they require a live Jupyter session. Executed via nbconvert --execute --inplace so nbsphinx renders the static viewer output directly in the built docs. https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
The executed outputs embedded ~4MB of HTML in the .ipynb (74k lines). Strip outputs so the notebook stays lightweight; nbsphinx is configured with nbsphinx_execute = 'never' so the docs render the code cells without re-executing. https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
Set nbsphinx_execute = 'always' so the notebook cells run during sphinx-build. The committed .ipynb stays clean (no outputs). https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
This file is generated during docs build and should not be tracked. https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
- Fix invalid port 65536 by using socket.bind(('', 0)) to find free ports
- Remove dead html_content read in display_static
- Tighten import guard: only suppress ImportError when IPython is missing
- Add StaticViewer class with close() for server/tmpdir cleanup
- Wrap make_static in try/except with clear RuntimeError on failure
- Log HTTP 4xx/5xx errors instead of suppressing all server output
- Use TemporaryDirectory context manager in make_notebook_html
- Use port 0 for HTTPServer to get OS-assigned free port
- Expand tests from 10 to 20: dispatch, port handling, cleanup, errors
The previous approach embedded ~4MB of HTML in an iframe srcdoc attribute, which broke JavaScript due to escaping issues. Now the notebook calls make_static() to generate the full viewer directory (HTML + CTM + data files) and references it via a regular IFrame src. Added a build-finished hook in conf.py to copy the generated static_viewer/ directory into the Sphinx build output. Verified with Playwright: brain renders correctly in ~2s. https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
The Jupyter notebook example requires nbsphinx (Sphinx extension), ipykernel (notebook execution), and pandoc (notebook conversion) to build during CI. https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
The upstream CI workflow doesn't install nbsphinx. Make it optional: only add the extension if importable, otherwise exclude the notebooks directory so the build doesn't fail on .ipynb files. Also keep the workflow update (nbsphinx + pandoc + ipykernel) for when the workflow is merged upstream. https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
There was a problem hiding this comment.
Pull request overview
This PR adds first-class Jupyter notebook support for embedding the cortex.webgl viewer, along with documentation and CI updates to build/render an example notebook in the Sphinx docs.
Changes:
- Add
cortex.webgl.jupyterwithdisplay()dispatch plus iframe/static helpers and aStaticViewerlifecycle handle. - Add a notebook-optimized WebGL HTML template and an example executed notebook in the docs.
- Update docs build configuration and workflows to support rendering notebooks (nbsphinx + dependencies).
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
cortex/webgl/jupyter.py |
New Jupyter integration module (iframe/static display + HTML generation helpers). |
cortex/webgl/notebook.html |
New HTML template variant tailored for notebook iframe usage. |
cortex/webgl/__init__.py |
Exposes cortex.webgl.jupyter when IPython is available. |
cortex/tests/test_jupyter_widget.py |
Adds unit tests for dispatch, port selection, and static viewer lifecycle. |
docs/notebooks/jupyter_notebook.ipynb |
New example notebook demonstrating usage. |
docs/notebooks/.gitignore |
Ignores artifacts generated during notebook execution. |
docs/index.rst |
Adds the notebook page to the docs toctree. |
docs/conf.py |
Conditionally enables nbsphinx and copies notebook artifacts into build output. |
.github/workflows/build_docs.yml |
Installs pandoc/nbsphinx/ipykernel and builds docs in CI. |
docs/.github/workflows/build_docs.yml |
Adds a second (duplicate) docs workflow under docs/ (note: not a standard Actions location). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
cortex/webgl/jupyter.py
Outdated
| import tempfile | ||
| import threading | ||
|
|
||
| from IPython.display import HTML, IFrame |
There was a problem hiding this comment.
HTML is imported from IPython.display but never used in this module. This adds an unnecessary hard dependency at import-time and may trigger unused-import checks in downstream tooling; please remove it or use it.
| from IPython.display import HTML, IFrame | |
| from IPython.display import IFrame |
cortex/webgl/jupyter.py
Outdated
|
|
||
| def close(self): | ||
| """Shut down the HTTP server and remove temp files.""" | ||
| try: | ||
| self._httpd.shutdown() | ||
| except Exception: | ||
| logger.warning("Failed to shut down static viewer server", exc_info=True) | ||
| try: | ||
| shutil.rmtree(self._tmpdir, ignore_errors=True) | ||
| except Exception: | ||
| logger.warning( | ||
| "Failed to clean up temp dir %s", self._tmpdir, exc_info=True | ||
| ) | ||
|
|
||
| def __del__(self): | ||
| try: | ||
| self._httpd.shutdown() | ||
| except Exception: | ||
| pass | ||
| try: | ||
| shutil.rmtree(self._tmpdir, ignore_errors=True) | ||
| except Exception: | ||
| pass |
There was a problem hiding this comment.
StaticViewer.close() only calls HTTPServer.shutdown() but never calls server_close() and never joins the serving thread. This can leave the listening socket open longer than intended and makes cleanup less reliable; consider making close() idempotent, calling shutdown() + server_close(), and joining the thread with a short timeout.
| def close(self): | |
| """Shut down the HTTP server and remove temp files.""" | |
| try: | |
| self._httpd.shutdown() | |
| except Exception: | |
| logger.warning("Failed to shut down static viewer server", exc_info=True) | |
| try: | |
| shutil.rmtree(self._tmpdir, ignore_errors=True) | |
| except Exception: | |
| logger.warning( | |
| "Failed to clean up temp dir %s", self._tmpdir, exc_info=True | |
| ) | |
| def __del__(self): | |
| try: | |
| self._httpd.shutdown() | |
| except Exception: | |
| pass | |
| try: | |
| shutil.rmtree(self._tmpdir, ignore_errors=True) | |
| except Exception: | |
| pass | |
| # Track shutdown state to make close() idempotent and thread-safe. | |
| self._closed = False | |
| self._lock = threading.Lock() | |
| def close(self, timeout=1.0): | |
| """Shut down the HTTP server, wait for the thread, and remove temp files. | |
| Parameters | |
| ---------- | |
| timeout : float, optional | |
| Maximum number of seconds to wait for the server thread to finish. | |
| """ | |
| # Ensure idempotent, thread-safe shutdown. | |
| try: | |
| lock = self._lock | |
| except AttributeError: | |
| # If attributes are partially initialized or being torn down, | |
| # fall back to best-effort cleanup without synchronization. | |
| lock = None | |
| if lock is not None: | |
| with lock: | |
| if getattr(self, "_closed", False): | |
| return | |
| self._closed = True | |
| else: | |
| if getattr(self, "_closed", False): | |
| return | |
| self._closed = True | |
| # Stop the HTTP server and close its listening socket. | |
| httpd = getattr(self, "_httpd", None) | |
| if httpd is not None: | |
| try: | |
| httpd.shutdown() | |
| httpd.server_close() | |
| except Exception: | |
| logger.warning( | |
| "Failed to shut down static viewer server", | |
| exc_info=True, | |
| ) | |
| # Give the serving thread a chance to exit cleanly. | |
| thread = getattr(self, "_thread", None) | |
| if thread is not None and thread.is_alive(): | |
| try: | |
| thread.join(timeout=timeout) | |
| except Exception: | |
| logger.warning( | |
| "Failed to join static viewer server thread", | |
| exc_info=True, | |
| ) | |
| # Finally, remove the temporary directory. | |
| tmpdir = getattr(self, "_tmpdir", None) | |
| if tmpdir is not None: | |
| try: | |
| shutil.rmtree(tmpdir, ignore_errors=True) | |
| except Exception: | |
| logger.warning( | |
| "Failed to clean up temp dir %s", tmpdir, exc_info=True | |
| ) | |
| def __del__(self): | |
| # Best-effort cleanup during garbage collection; avoid raising. | |
| try: | |
| self.close(timeout=0.1) | |
| except Exception: | |
| pass |
| Usage | ||
| ----- | ||
| >>> import cortex | ||
| >>> vol = cortex.Volume.random("S1", "fullhead") | ||
| >>> cortex.webgl.jupyter.display(vol) # auto-detects best approach | ||
| """ |
There was a problem hiding this comment.
The module docstring example says cortex.webgl.jupyter.display(vol) “auto-detects best approach”, but display() defaults to method="iframe" and does not perform any auto-detection. Please update the docstring/example to match the actual behavior (or implement auto-detection if that’s intended).
cortex/webgl/jupyter.py
Outdated
| """Display brain data as a self-contained HTML viewer inline. | ||
|
|
||
| Generates a complete static viewer with all JS/CSS/data embedded, | ||
| then displays it in the notebook via a lightweight local HTTP server. | ||
|
|
||
| Note: The embedded HTML is large (~4-5MB) because all JavaScript | ||
| libraries and CSS are inlined. |
There was a problem hiding this comment.
display_static() is documented as generating a “self-contained HTML viewer” with “all JS/CSS/data embedded” and as working in static notebook renderers, but view.make_static(..., html_embed=True) still writes CTM/JSON/PNG assets alongside index.html (and the HTML expects to fetch them). The docs should clarify that the output is a directory that must be served (and isn’t a single self-contained HTML string).
| """Display brain data as a self-contained HTML viewer inline. | |
| Generates a complete static viewer with all JS/CSS/data embedded, | |
| then displays it in the notebook via a lightweight local HTTP server. | |
| Note: The embedded HTML is large (~4-5MB) because all JavaScript | |
| libraries and CSS are inlined. | |
| """Display brain data using a temporary static WebGL viewer inline. | |
| This function uses ``cortex.webgl.make_static`` to generate a *directory* | |
| containing ``index.html`` plus all required JS/CSS/data assets, then serves | |
| that directory via a lightweight local HTTP server and embeds it in the | |
| notebook inside an ``<iframe>``. | |
| Note | |
| ---- | |
| - The output is **not** a single, self-contained HTML string; it is a | |
| static viewer directory that must be served for the page to function. | |
| - This works in live Jupyter environments that can run a local HTTP server, | |
| but most static notebook renderers will not execute the server and will | |
| therefore not display the interactive viewer. |
| def make_notebook_html(data, template="static.html", types=("inflated",), **kwargs): | ||
| """Generate a self-contained HTML string for the WebGL viewer. | ||
|
|
||
| This is a lower-level function that returns the raw HTML string rather | ||
| than displaying it. Useful for saving or embedding in custom contexts. | ||
|
|
There was a problem hiding this comment.
make_notebook_html() claims to return a “self-contained HTML string”, but it currently returns only the generated index.html while the viewer still depends on adjacent asset files produced by make_static() (CTM/JSON/PNGs). Either adjust the docstring to reflect that additional assets are required, or extend the implementation to truly inline those assets if self-contained HTML is required.
cortex/webgl/jupyter.py
Outdated
| httpd = http.server.HTTPServer(("127.0.0.1", 0), _QuietHandler) | ||
| port = httpd.server_address[1] | ||
|
|
||
| thread = threading.Thread(target=httpd.serve_forever, daemon=True) | ||
| thread.start() | ||
|
|
||
| iframe = IFrame( | ||
| src="http://127.0.0.1:%d/index.html" % port, width=width_str, height=height |
There was a problem hiding this comment.
The static-mode server is bound to 127.0.0.1 and the iframe src also uses 127.0.0.1. This will not work for common remote-notebook scenarios (JupyterHub/SSH remote kernels) because the browser’s 127.0.0.1 is the client machine, not the notebook server. Consider making the bind host / iframe host configurable (or using serve.hostname / a notebook-proxy approach) so this feature works beyond purely local notebooks.
| httpd = http.server.HTTPServer(("127.0.0.1", 0), _QuietHandler) | |
| port = httpd.server_address[1] | |
| thread = threading.Thread(target=httpd.serve_forever, daemon=True) | |
| thread.start() | |
| iframe = IFrame( | |
| src="http://127.0.0.1:%d/index.html" % port, width=width_str, height=height | |
| # Host for the temporary HTTP server and iframe. Default to 127.0.0.1 for | |
| # backwards compatibility, but allow override for remote/proxied setups. | |
| host = os.environ.get("CORTEX_JUPYTER_STATIC_HOST", "127.0.0.1") | |
| httpd = http.server.HTTPServer((host, 0), _QuietHandler) | |
| port = httpd.server_address[1] | |
| thread = threading.Thread(target=httpd.serve_forever, daemon=True) | |
| thread.start() | |
| iframe = IFrame( | |
| src="http://%s:%d/index.html" % (host, port), width=width_str, height=height |
| ] | ||
|
|
||
| exclude_patterns = ["_build", "auto_examples/**/*.ipynb"] | ||
|
|
||
| # nbsphinx – render and execute Jupyter notebooks in docs | ||
| # Only enable if nbsphinx (and its deps: pandoc, ipykernel) are installed | ||
| try: | ||
| import nbsphinx # noqa: F401 | ||
|
|
||
| extensions.append("nbsphinx") | ||
| nbsphinx_execute = "always" | ||
| except ImportError: | ||
| # nbsphinx not installed – exclude notebook sources so the build | ||
| # doesn't fail on unrecognised .ipynb files in the toctree | ||
| exclude_patterns.append("notebooks") | ||
|
|
There was a problem hiding this comment.
When nbsphinx isn’t installed, exclude_patterns.append("notebooks") hides the notebook sources, but docs/index.rst always includes notebooks/jupyter_notebook in the toctree. This will produce a “toctree contains reference to nonexisting document” warning/error. Either make nbsphinx a required docs dependency (remove the try/except) or conditionally include the notebook in the toctree.
| ] | |
| exclude_patterns = ["_build", "auto_examples/**/*.ipynb"] | |
| # nbsphinx – render and execute Jupyter notebooks in docs | |
| # Only enable if nbsphinx (and its deps: pandoc, ipykernel) are installed | |
| try: | |
| import nbsphinx # noqa: F401 | |
| extensions.append("nbsphinx") | |
| nbsphinx_execute = "always" | |
| except ImportError: | |
| # nbsphinx not installed – exclude notebook sources so the build | |
| # doesn't fail on unrecognised .ipynb files in the toctree | |
| exclude_patterns.append("notebooks") | |
| "nbsphinx", | |
| ] | |
| exclude_patterns = ["_build", "auto_examples/**/*.ipynb"] | |
| nbsphinx_execute = "always" |
docs/conf.py
Outdated
| src = os.path.join(app.srcdir, "notebooks", "static_viewer") | ||
| dst = os.path.join(app.outdir, "notebooks", "static_viewer") | ||
| if os.path.isdir(src) and not os.path.isdir(dst): |
There was a problem hiding this comment.
_copy_notebook_artifacts runs even when the build failed (it ignores the exception argument) and only copies if the destination directory doesn’t already exist. Consider returning early when exception is not None, and copying with overwrite semantics (e.g., remove existing dst first or use dirs_exist_ok=True) to avoid stale notebook artifacts on incremental builds.
| src = os.path.join(app.srcdir, "notebooks", "static_viewer") | |
| dst = os.path.join(app.outdir, "notebooks", "static_viewer") | |
| if os.path.isdir(src) and not os.path.isdir(dst): | |
| # Do not attempt to copy artifacts if the build failed. | |
| if exception is not None: | |
| return | |
| src = os.path.join(app.srcdir, "notebooks", "static_viewer") | |
| dst = os.path.join(app.outdir, "notebooks", "static_viewer") | |
| if os.path.isdir(src): | |
| # Remove any existing destination to avoid stale artifacts, | |
| # then copy the current notebook static viewer files. | |
| if os.path.isdir(dst): | |
| shutil.rmtree(dst) |
| name: Build docs | ||
|
|
||
| on: | ||
| push: | ||
| branches: | ||
| - main | ||
| tags: | ||
| - '*' | ||
| pull_request: | ||
| branches: | ||
| - main | ||
| workflow_dispatch: # Manual trigger for publishing docs | ||
|
|
||
| jobs: | ||
| build-docs: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
|
|
||
| - name: Set up Python | ||
| uses: actions/setup-python@v6 | ||
| with: | ||
| python-version: 3.12 | ||
|
|
||
| - uses: actions/cache@v5 | ||
| with: | ||
| path: ~/.cache/pip | ||
| key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py', '**/pyproject.toml') }} | ||
| restore-keys: | | ||
| ${{ runner.os }}-pip- | ||
|
|
||
| - name: Install dependencies | ||
| run: | | ||
| sudo apt-get update | ||
| sudo apt-get install -y inkscape --no-install-recommends | ||
| pip install --upgrade pip | ||
| pip install wheel setuptools numpy cython | ||
| # force using latest nibabel | ||
| pip install -U nibabel | ||
| pip install -q ipython Sphinx sphinx-gallery numpydoc # TODO: move to pyproject.toml | ||
| pip install -e . --no-build-isolation --group dev | ||
| python -c 'import cortex; print(cortex.__full_version__)' | ||
|
|
||
| - name: Cache Playwright browsers | ||
| uses: actions/cache@v5 | ||
| with: | ||
| path: ~/.cache/ms-playwright | ||
| key: ${{ runner.os }}-playwright-${{ hashFiles('**/pyproject.toml') }} | ||
|
|
||
| - name: Install Playwright Chromium | ||
| run: playwright install --with-deps --only-shell chromium | ||
|
|
||
| - name: Build documents | ||
| timeout-minutes: 20 | ||
| run: | | ||
| cd docs && make html && cd .. | ||
| touch docs/_build/html/.nojekyll | ||
|
|
||
| - name: Publish to gh-pages if tagged | ||
| if: startsWith(github.ref, 'refs/tags') || github.event_name == 'workflow_dispatch' | ||
| uses: JamesIves/github-pages-deploy-action@v4.8.0 | ||
| with: | ||
| branch: gh-pages | ||
| folder: docs/_build/html |
There was a problem hiding this comment.
GitHub Actions only loads workflows from the repository root at .github/workflows/. This workflow file under docs/.github/workflows/ won’t run and duplicates the root docs workflow, which can be confusing to maintain. Consider removing it or moving it to .github/workflows/ if it’s meant to be active.
| name: Build docs | |
| on: | |
| push: | |
| branches: | |
| - main | |
| tags: | |
| - '*' | |
| pull_request: | |
| branches: | |
| - main | |
| workflow_dispatch: # Manual trigger for publishing docs | |
| jobs: | |
| build-docs: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Set up Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: 3.12 | |
| - uses: actions/cache@v5 | |
| with: | |
| path: ~/.cache/pip | |
| key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py', '**/pyproject.toml') }} | |
| restore-keys: | | |
| ${{ runner.os }}-pip- | |
| - name: Install dependencies | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y inkscape --no-install-recommends | |
| pip install --upgrade pip | |
| pip install wheel setuptools numpy cython | |
| # force using latest nibabel | |
| pip install -U nibabel | |
| pip install -q ipython Sphinx sphinx-gallery numpydoc # TODO: move to pyproject.toml | |
| pip install -e . --no-build-isolation --group dev | |
| python -c 'import cortex; print(cortex.__full_version__)' | |
| - name: Cache Playwright browsers | |
| uses: actions/cache@v5 | |
| with: | |
| path: ~/.cache/ms-playwright | |
| key: ${{ runner.os }}-playwright-${{ hashFiles('**/pyproject.toml') }} | |
| - name: Install Playwright Chromium | |
| run: playwright install --with-deps --only-shell chromium | |
| - name: Build documents | |
| timeout-minutes: 20 | |
| run: | | |
| cd docs && make html && cd .. | |
| touch docs/_build/html/.nojekyll | |
| - name: Publish to gh-pages if tagged | |
| if: startsWith(github.ref, 'refs/tags') || github.event_name == 'workflow_dispatch' | |
| uses: JamesIves/github-pages-deploy-action@v4.8.0 | |
| with: | |
| branch: gh-pages | |
| folder: docs/_build/html | |
| # NOTE: This file is intentionally not an active GitHub Actions workflow. | |
| # | |
| # GitHub Actions only loads workflows from the repository root at: | |
| # .github/workflows/ | |
| # | |
| # The actual docs build workflow lives in: | |
| # .github/workflows/build_docs.yml | |
| # | |
| # This stub is kept only to avoid confusion if someone looks under | |
| # docs/.github/workflows/. Do not add a `name:`, `on:`, or `jobs:` key here, | |
| # as this file is not used by GitHub Actions. |
| mock_make_static.side_effect = self._fake_make_static | ||
| result = display_static("fake_data", width=800) | ||
|
|
||
| mock_display.assert_called_once() |
There was a problem hiding this comment.
test_width_int_converted doesn’t currently assert that the integer width is converted to a pixel string in the produced IFrame (it only asserts that display was called). To actually cover the behavior, inspect the IFrame argument (e.g., iframe_arg.width) or the StaticViewer.iframe and assert it matches the expected "px" format.
| mock_display.assert_called_once() | |
| mock_display.assert_called_once() | |
| iframe_arg = mock_display.call_args[0][0] | |
| self.assertEqual("800px", iframe_arg.width) |
- Remove unused HTML import - Make StaticViewer.close() idempotent with server_close() and thread.join() - Add viewer registry with close_all() and atexit cleanup to prevent tmp buildup - Fix misleading docstrings (auto-detect claim, self-contained HTML claims) - Make static host configurable via CORTEX_JUPYTER_STATIC_HOST env var - Add TOCTOU race documentation to _find_free_port - Make nbsphinx a required docs dependency (fixes toctree mismatch) - Fix _copy_notebook_artifacts to skip on failure and overwrite stale builds - Remove misplaced docs/.github/workflows/build_docs.yml - Fix test_width_int_converted to actually assert IFrame width - Add tests for viewer registry and close_all

Summary
cortex.webgl.jupytermodule with two display methods for embedding the WebGL brain viewer in Jupyter notebooks:display(data)— IFrame mode: starts Tornado server in background thread, embeds via IFrame. Full interactivity with WebSocket/JSMixer control.display(data, method="static")— Static mode: generates self-contained viewer via local HTTP server. ReturnsStaticViewerhandle withclose()for cleanup.make_notebook_html(data)— returns raw HTML string for custom embedding.socket.bind(('', 0))instead ofrandom.randintStaticViewerclass with proper resource cleanup (HTTP server shutdown + temp dir removal)make_staticfailures produce clearRuntimeError, HTTP errors loggednbsphinxto Sphinx docs to render example notebook with live WebGL viewerNew files
cortex/webgl/jupyter.py— main modulecortex/webgl/notebook.html— notebook-optimized HTML templatecortex/tests/test_jupyter_widget.py— testsdocs/notebooks/jupyter_notebook.ipynb— example notebook (executed at docs build time)Test plan
python -m pytest cortex/tests/test_jupyter_widget.py -v— 20 tests passcd docs && make html)display(vol)in live Jupyter notebookdisplay(vol, method="static")in live Jupyter notebookviewer.close()cleans up server and temp files