Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5a1e0dd
Speed up get_render(): flush requests, decode off the event loop, fix…
brentyi Jul 26, 2026
f9dfff2
get_render(): same-frame capture, raw transport, encode off the criti…
brentyi Jul 26, 2026
fc567f6
e2e: pin fresh-node pose timing for same-frame capture; deflake forma…
brentyi Jul 26, 2026
830802a
get_render(): default to raw transport, split into raw_rgb / raw_rgba
brentyi Jul 26, 2026
4847b36
get_render(): keep jpeg as the default transport
brentyi Jul 26, 2026
b239e0c
get_render(): adaptively deflate raw payloads on the wire
brentyi Jul 26, 2026
84dbb32
get_render(): content-adaptive "auto" transport, now the default
brentyi Jul 26, 2026
e6c94c2
get_render(): drop "auto", default to raw_rgb
brentyi Jul 27, 2026
8f9a088
get_render(): rename raw_rgb / raw_rgba to deflate_rgb / deflate_rgba
brentyi Jul 27, 2026
720c510
get_render(): raise the deflate gate to 4x, informed by a compressibi…
brentyi Jul 27, 2026
c8c9c96
get_render(): honor JPEG quality on the client, expose jpeg_quality
brentyi Jul 27, 2026
b10bca5
get_render(): drop the jpeg_quality parameter, keep quality fixed at 80
brentyi Jul 27, 2026
f1cb0c6
get_render(): restore "auto" as the default transport
brentyi Jul 27, 2026
4483ea2
get_render(): drop the adaptive transports, back to jpeg (default) an…
brentyi Jul 27, 2026
e053f31
get_render(): fix splat state leaking into the interactive view; add …
brentyi Jul 28, 2026
9547d78
get_render(): fixes from an adversarial review of the capture path
brentyi Jul 28, 2026
b889f64
get_render(): document that asynchronously-decoded assets are not awa…
brentyi Jul 28, 2026
5c59128
tests: construct the get_render test buffer on the server's event loop
brentyi Jul 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ lint.ignore = [
"PLR0915", # Too many statements.
"PLR0913", # Too many arguments.
"PLR0917", # Too many positional arguments. (stabilized in newer ruff; same intent as PLR0913 above)
"PLW0108", # Lambda may be unnecessary. (stabilized in newer ruff; flags pre-existing callback-style lambdas)
"PLC0414", # Import alias does not rename variable. (this is used for exporting names)
"PLC0415", # Import should be at the top-level of a file.
"PLC1901", # Use falsey strings.
Expand Down
8 changes: 8 additions & 0 deletions src/viser/_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -2356,6 +2356,14 @@ class GetRenderRequestMessage(Message, include_in_scene_serialization=False):
# calls on the same client can be matched to their responses.
render_uuid: str

@override
def redundancy_key(self) -> str:
# Every in-flight request must survive independently in the outgoing
# buffer. Under the class-name default key, a second concurrent
# get_render() on the same client evicted the first caller's
# still-unsent request, hanging that caller until timeout.
return type(self).__name__ + "-" + self.render_uuid


@dataclasses.dataclass
class GetRenderResponseMessage(Message, include_in_scene_serialization=False):
Expand Down
84 changes: 59 additions & 25 deletions src/viser/_viser.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,8 +510,9 @@ def get_render(
Args:
height: Height of rendered image. Should be <= the browser height.
width: Width of rendered image. Should be <= the browser width.
transport_format: Image transport format. JPEG will return a lossy (H, W, 3) RGB array. PNG will
return a lossless (H, W, 4) RGBA array, but can cause memory issues on the frontend if called
transport_format: Image transport format. JPEG (default) returns a lossy (H, W, 3) RGB
array with a small payload on any content. PNG returns a lossless (H, W, 4) RGBA array
with a transparent background, but can cause memory issues on the frontend if called
too quickly for higher-resolution images.
timeout: Optional maximum seconds to wait for the frame. ``None``
(default) waits indefinitely; a disconnect still raises promptly
Expand Down Expand Up @@ -759,19 +760,30 @@ def get_render(
be used.
fov: Vertical field of view of the camera, in radians. If not provided, the
current camera position will be used.
transport_format: Image transport format. JPEG will return a lossy (H, W, 3) RGB array. PNG will
return a lossless (H, W, 4) RGBA array, but can cause memory issues on the frontend if called
transport_format: Image transport format. JPEG (default) returns a lossy (H, W, 3) RGB
array with a small payload on any content. PNG returns a lossless (H, W, 4) RGBA array
with a transparent background, but can cause memory issues on the frontend if called
too quickly for higher-resolution images.
timeout: Optional maximum seconds to wait for the frame. ``None``
(default) waits indefinitely; a disconnect still raises promptly
either way. Set this to bound a client that stays connected but
never returns a frame (raises ``TimeoutError``).

Note:
Captures reflect all scene *state* updates (poses, colors, visibility,
geometry props) made before the call. Content that the browser decodes
or loads asynchronously -- large background/image textures, GLB assets,
environment maps -- is not awaited: a capture issued immediately after
such an update may still show the previous content if the decode hasn't
finished (more likely on fast displays, where frames are short relative
to decode time). When that matters, capture after the asset has had a
moment to load.
"""

# Listen for a render reseponse message, which should contain the rendered
# image.
render_ready_event = threading.Event()
out: np.ndarray | None = None
payload: bytes | None = None

connection = self._websock_connection

Expand Down Expand Up @@ -810,32 +822,34 @@ def got_render_cb(
# dispatch loop snapshotted the handler list before the
# removal. The caller is gone -- drop the frame.
return
nonlocal out
# An empty payload is the client's failure sentinel (capture threw,
# or toBlob() returned null). Leave `out` as None and let the
# waiter raise, rather than crashing the decode here (which would
# never set the event and hang get_render() forever).
if len(message.payload) > 0:
import imageio.v3 as iio

try:
out = iio.imread(
io.BytesIO(message.payload),
extension=f".{transport_format}",
)
except Exception:
out = None
nonlocal payload
# Store the raw payload only; decoding happens on the caller's
# thread below. This callback runs on the server's event loop,
# where a large PNG/JPEG decode (or imageio's slow first import)
# would block message handling for EVERY client.
payload = message.payload
render_ready_event.set()

connection.register_handler(_messages.GetRenderResponseMessage, got_render_cb)
# Kick any windowed BROADCAST messages (server.scene updates, which
# ride a different buffer than this request) toward the wire before
# queueing the request: a capture should reflect scene updates made
# before the get_render() call. Best-effort, not a guarantee -- the
# two buffers are drained by independent producer tasks, so a
# backlogged broadcast producer can still lose the race -- but
# flushing first makes the request overtaking a scene update rare
# instead of routine (~one windowing delay of exposure).
self._viser_server.flush()
self._websock_connection.queue_message(
_messages.GetRenderRequestMessage(
"image/jpeg" if transport_format == "jpeg" else "image/png",
height=height,
width=width,
# Only used for JPEG. The main reason to use a lower quality version
# value is (unfortunately) to make life easier for the Javascript
# garbage collector.
# Only used for JPEG. Measured: JPEG speed and size move
# together (lower quality = fewer surviving DCT coefficients
# = less entropy-coding work on both ends), and 80 sits near
# the speed plateau while staying fidelity-safe. Chrome's
# 0.92 toBlob default is strictly slower and larger.
quality=80,
position=cast_vector(
position if position is not None else self.camera.position, 3
Expand All @@ -845,6 +859,16 @@ def got_render_cb(
render_uuid=render_uuid,
)
)
# Outgoing messages are windowed by default (up to ~1/60s of batching
# delay before they hit the wire). For a blocking round trip that
# delay is pure added latency, so flush the request out immediately.
self.flush()

# Import the decoder while the client is busy rendering: on first use
# this import is slow, and doing it here (request already in flight)
# overlaps it with the round trip instead of adding to it.
import imageio.v3 as iio

# Poll rather than wait unbounded: a client that DISCONNECTS (tab
# closed, network drop) never sends a response, so this raises as soon
# as it leaves _connected_clients instead of hanging the caller (and,
Expand Down Expand Up @@ -875,11 +899,21 @@ def got_render_cb(
f"Render request timed out after {timeout}s: the client "
"did not return a frame."
)
if out is None:
# An empty payload is the client's failure sentinel (capture threw, or
# toBlob() returned null).
if payload is None or len(payload) == 0:
raise RuntimeError(
"Render request failed: the client could not capture a frame."
)
return out
try:
return iio.imread(
io.BytesIO(payload),
extension=f".{transport_format}",
)
except Exception as e:
raise RuntimeError(
"Render request failed: the client could not capture a frame."
) from e


class ViserServer(DeprecatedAttributeShim if not TYPE_CHECKING else object):
Expand Down
Loading
Loading