#3580·mujoco

Studio web viewer is unusable: studio.web.headless_ui is missing from the wheel

Author: larrygao001Created Sep 14, 2026Updated Sep 17, 2026
Labelsbug

Intro

Hi!

I'm from AWS. I use MuJoCo for headless physics simulation and offscreen rendering distributed across many workers, and I have been trying Studio's Python bindings to add an in-viewer UI.

My setup

Reproduced on both:

  • MuJoCo 3.13.0 (PyPI wheel), Python 3.11.5, arm64, macOS 26.6.2
  • MuJoCo 3.13.0 (PyPI wheel), Python 3.14.4, x86_64, Ubuntu 26.04

API: Python.

What's happening? What did you expect?

web_viewer.py imports headless_ui from mujoco.experimental.studio.web, but that module is not present in the wheel. Selecting the web or webgl graphics mode therefore fails.

viewer_protocol.GFX_MODES advertises both modes:

python
('classic', 'classic_headless', 'opengl', 'opengl_headless',
 'opengl_software', 'vulkan', 'vulkan_software', 'web', 'webgl')

so they are selectable but cannot work. I expected either a working web viewer, or for the unavailable modes not to be offered.

The failure also arrives awkwardly. It happens on the viewer thread, so launch_passive returns a handle and is_running() reports True at first, turning False only later:

launch_passive returned: ViewerHandle is_running: True
Exception in thread Thread-1 (target):
Traceback (most recent call last):
  ...
  File ".../studio/launch_web.py", line 39, in run_web_viewer
    from mujoco.experimental.studio import web_viewer
  File ".../studio/web_viewer.py", line 51, in <module>
    from mujoco.experimental.studio.web import headless_ui
ImportError: cannot import name 'headless_ui' from
'mujoco.experimental.studio.web' (unknown location)
is_running after 5s: False

Process exit status is 0, so a script that does not poll is_running() will not notice at all.

Cause

The shipped web package contains only web_server.py:

$ ls site-packages/mujoco/experimental/studio/web/
__pycache__  web_server.py

There is also no __init__.py, which is why the message says "unknown location".

Why it matters beyond the web viewer

On macOS this is currently the only Studio backend that avoids the main-thread window problem (filed separately), because rendering happens in the browser. With it unavailable, there is no working way to run Studio from Python on macOS without a workaround.

Suggested fix

Ship headless_ui, and an __init__.py for the web package. Alternatively, remove web and webgl from GFX_MODES until they ship, so the failure surfaces at configuration time rather than as a traceback on a background thread.

Steps for reproduction

  1. pip install mujoco==3.13.0
  2. Confirm the module is absent: python -c "from mujoco.experimental.studio.web import headless_ui"
  3. Or go through the documented path: save minimal.xml and repro.py below into the same directory and run python repro.py

Minimal model for reproduction

The model is not material to this bug, since the failure occurs during viewer startup before the model is used. Included for completeness, loadable as-is with no binary assets:

xml
<mujoco model="studio_repro">
  <option timestep="0.002"/>
  <worldbody>
    <light pos="0 0 2"/>
    <camera name="fixed" pos="0.8 -0.8 0.6" xyaxes="1 1 0 -0.4 0.4 1"/>
    <geom name="floor" type="plane" size="2 2 0.05" rgba="0.4 0.45 0.5 1"/>
    <body name="box" pos="0 0 0.3">
      <freejoint name="box_free"/>
      <geom name="box" type="box" size="0.05 0.05 0.05" rgba="0.8 0.2 0.2 1"/>
    </body>
  </worldbody>
</mujoco>

Code required for reproduction

Two lines are enough:

python
from mujoco.experimental.studio.web import headless_ui

Or via the public API, repro.py:

python
import time
import mujoco
from mujoco.experimental.studio import launch_passive, viewer_protocol

model = mujoco.MjModel.from_xml_path("minimal.xml")
handle = launch_passive.launch_passive(
    viewer_protocol.ViewerConfig(title="studio web repro", gfx="web", http_port=8099)
)
print("launch_passive returned:", type(handle).__name__, "is_running:", handle.is_running())
time.sleep(5)          # give the viewer thread time to fail
print("is_running after 5s:", handle.is_running())

The time.sleep matters: without it the script exits before the viewer thread reaches the failing import, and nothing is printed at all.

Confirmations