MuJoCo Web Viewer: Implement parallel chunked model downloading and in-place model reloading.

This change introduces parallel chunked downloading for large model files (.mjb) directly into WASM linear memory, enables model loading without full page reloads and reduces memory overhead.

Key changes:
- Implement a chunked model endpoint in the Python web server to support range requests.
- Add parallel chunked fetching in the frontend with retry logic and a single-fetch fallback.
- Increase initial WASM memory to 3 GB to accommodate large models and prevent heap fragmentation.
- Support in-place model reloading in the C++ client, including texture cache invalidation when the Filament context is recreated.
- Display a model download progress bar and model parsing/loading banner to the UI.
- Fixes model drag and drop (caused by typo in sessionId, corrected to session_id).

PiperOrigin-RevId: 960249236
Change-Id: Icac89e6a4ca099882aaf9b111744c1b6ab0cc6c1
This commit is contained in:
Matija Kecman
2026-08-06 05:51:01 -07:00
committed by Copybara-Service
parent 7fd2061f5c
commit 84950fa371
11 changed files with 406 additions and 98 deletions
@@ -16,7 +16,7 @@
This server runs in a child process with one asyncio loop on a single public
port:
* Plain HTTP GET serves static files (index.html, WASM, assets), /model.mjb.
* Plain HTTP GET serves static files (index.html, WASM, assets), /model.
* WebSocket /ui serves the bridge to the headless NetImgui client, which
connects over loopback TCP (see headless_ui.cc).
* WebSocket /state serves the latest-wins state payload broadcast at ~60Hz
@@ -40,6 +40,7 @@ import asyncio
import ctypes
import datetime
import enum
import json
import logging
import multiprocessing
import multiprocessing.queues
@@ -50,6 +51,7 @@ import socket
import struct
import sys
import threading
import urllib.parse
from typing import Any, Awaitable, Callable, Optional, cast
from websockets.asyncio.server import serve
@@ -123,7 +125,7 @@ _NETIMGUI_CMD_VERSION_SIZE = 120
# reconnects within ~1s in the normal case; this only bounds the pathological
# one where it never appears (e.g., the headless UI failed to start), so a stuck
# controller cannot lock every other browser out forever.
_UI_TCP_WAIT_SEC = 15.0
_UI_TCP_WAIT_SEC = 30.0
# Content types for the static files the HTTP handler serves.
_CONTENT_TYPES = {
@@ -446,7 +448,7 @@ def _run_server(
def _serve_http(path: str) -> Response:
"""Builds the HTTP response for a non-WebSocket GET request."""
if path == "/model.mjb":
if path == "/model":
if not mjb_data:
return Response(404, "Not Found", Headers(), b"no model\n")
# The model changes on hot-swap; never serve a cached copy.
@@ -947,9 +949,44 @@ def _run_server(
connection: ServerConnection, request: Request
) -> Optional[Response]:
del connection
path = request.path.split("?")[0]
# Split path and query string.
if "?" in request.path:
path, query_string = request.path.split("?", 1)
else:
path, query_string = request.path, ""
if path in ("/ui", "/state", "/drop"):
return None # Proceed with the WebSocket handshake.
# Chunked model endpoint: the client fetches /model in parallel chunks
#
# GET /model?total_bytes -> {"total_bytes": <n>}
# GET /model?offset_bytes=X&size_bytes=Y -> bytes [X, X+Y)
#
# Full model endpoint: the client fetches /model in a single request
#
# GET /model -> full model bytes
if path == "/model" and mjb_data and query_string:
params = urllib.parse.parse_qs(query_string)
if "total_bytes" in params or query_string == "total_bytes":
body = json.dumps({"total_bytes": len(mjb_data)}).encode()
headers = _http_headers(
"application/json", len(body), cacheable=False
)
return Response(200, "OK", headers, body)
try:
offset = int(params.get("offset_bytes", [0])[0])
size = int(params.get("size_bytes", [0])[0])
except (ValueError, TypeError):
return Response(400, "Bad Request", Headers(), b"bad params\n")
if size <= 0 or offset < 0 or offset >= len(mjb_data):
return Response(400, "Bad Request", Headers(), b"bad range\n")
chunk = mjb_data[offset : min(offset + size, len(mjb_data))]
headers = _http_headers(
"application/octet-stream", len(chunk), cacheable=False
)
return Response(200, "OK", headers, chunk)
return _serve_http(path)
def process_response(
@@ -1089,7 +1126,10 @@ def _run_server(
process_request=process_request,
process_response=process_response,
compression=None,
close_timeout=1.0,
ping_interval=None,
ping_timeout=None,
open_timeout=None,
close_timeout=None,
# Big enough for model files uploaded via /drop.
max_size=2**26,
)
@@ -1097,7 +1137,7 @@ def _run_server(
tcp_port = tcp_sock.getsockname()[1]
logger.debug(
"[Http] Serving on http://%s:%d "
"(/, /model.mjb, /ui, /state; NetImgui TCP on 127.0.0.1:%d)",
"(/, /model, /ui, /state; NetImgui TCP on 127.0.0.1:%d)",
http_host,
http_port,
tcp_port,
@@ -1147,7 +1187,7 @@ class WebServer:
http_sock: The listening socket for HTTP and WebSocket traffic.
tcp_sock: The listening socket for NetImgui traffic.
static_files_dir: The directory containing the static files to serve.
mjb_data: The model data to serve from /model.mjb.
mjb_data: The model data to serve from /model.
max_payload_size: The maximum size of the state payload.
drop_queue: The queue to put dropped files onto.
controller_sid_shared: The shared value containing the controller's