diff --git a/examples/cube_http.py b/examples/cube_http.py
new file mode 100644
index 0000000..822a02e
--- /dev/null
+++ b/examples/cube_http.py
@@ -0,0 +1,59 @@
+"""
+Cube in the browser
+-------------------
+
+Run a wgpu example with the http backend. Note that the http backend can be used
+with most examples by simply using ``from rendercanvas.http import RenderCanvas,
+loop``. This example also shows how the web-page can be customized.
+
+Also see fastapi_app.py for how to integrate a rendercanvas into a larger web
+application.
+"""
+
+# run_example = false
+
+from rendercanvas.http import RenderCanvas, loop, resources
+from rendercanvas.utils.cube import setup_drawing_sync
+from rendercanvas.core.encoders import encode_png
+import numpy as np
+
+
+canvas = RenderCanvas(
+ title="The wgpu cube example on $backend", update_mode="continuous"
+)
+draw_frame = setup_drawing_sync(canvas)
+canvas.request_draw(draw_frame)
+
+
+# Define custom HTML. This is optional.
+html = """
+
+
+ RenderCanvas over http
+
+
+
+
+
+
+
+
+ Loading ...
+
+
+
+
+
+"""
+
+# The resources is simply a dict that maps filenames to (content-type, body) tuples.
+resources["index.html"] = "text/html", html
+
+
+# You can also add new resources, like images or even extra web pages.
+im = np.random.uniform(0, 255, (16, 16, 3)).astype(np.uint8)
+resources["logo.png"] = "image/png", encode_png(im)
+
+
+# the loop.run() of this backend uses uvicorn to start a webserver
+loop.run()
diff --git a/examples/fastapi_app.py b/examples/fastapi_app.py
new file mode 100644
index 0000000..4bb8550
--- /dev/null
+++ b/examples/fastapi_app.py
@@ -0,0 +1,53 @@
+"""
+FastAPI
+-------
+
+Rendercanvas can do remote rendering as part of a web application.
+It implements its own little ASGI application, that can be mounted
+as part of a larger web application. This example demonstrates this
+with the FastAPI web framework.
+
+You can now run this like any AGI app, e.g. with uvicorn:
+
+ uvicorn fastapi_app:app
+
+"""
+
+from fastapi import FastAPI
+from fastapi.responses import HTMLResponse
+from rendercanvas.http import RenderCanvas, asgi
+from rendercanvas.utils.cube import setup_drawing_sync
+
+
+# FastAPI code
+
+app = FastAPI()
+
+
+@app.get("/", response_class=HTMLResponse)
+async def home():
+ return """
+
+
+
+ Test
+
+
+ Hello world
+ Head over to the rendercanvas client
+
+
+ """
+
+
+# Prepare a canvas to render something
+
+canvas = RenderCanvas(
+ title="The wgpu cube example on $backend", update_mode="continuous"
+)
+draw_frame = setup_drawing_sync(canvas)
+canvas.request_draw(draw_frame)
+
+
+# Mount rendercanvas in the app
+app.mount("/rc", asgi)
diff --git a/rendercanvas/core/renderview-client.js b/rendercanvas/core/renderview-client.js
new file mode 100644
index 0000000..3d00e17
--- /dev/null
+++ b/rendercanvas/core/renderview-client.js
@@ -0,0 +1,172 @@
+/*************************************************************************************************
+ renderview-client.js
+
+ Code to use renderview in a remote browser (rendercanvas http backend).
+ There are basically two approaches to take. Either use renderview-afm.js and re-use the render logic,
+ but implement an AFM host. Or directly attach a RenderView to a websocket. I went for the latter. Even
+ though that means duplicating some code, it looks like this leads to simpler code.
+
+ *************************************************************************************************/
+
+/* global BaseRenderView WebSocket */
+
+
+const wrapperElement = document.getElementById('canvas')
+const statusElement = document.getElementById('status')
+let view = null
+let websocket = null
+let isActive = null
+
+updateStatus()
+openWebsocketConnection()
+window.openWebsocketConnection = openWebsocketConnection
+
+
+class ClientRenderView extends BaseRenderView {
+ constructor(wrapperElement) {
+ wrapperElement.classList.add('renderview-wrapper')
+
+ // Create view element
+ const viewElement = document.createElement('img')
+ viewElement.decoding = 'sync'
+ viewElement.loading = 'eager'
+ viewElement.style.touchAction = 'none' // prevent default pan/zoom behavior
+ viewElement.ondragstart = () => false // prevent browser's built-in image drag
+
+ // Instantiate
+ super(viewElement, wrapperElement)
+ this.setThrottle(20) // 20ms -> max 50 move/wheel events per second
+
+ this.frames = []
+ this.imgUpdatePending = false
+ this.lastSrc = null
+ }
+
+ onEvent(event) {
+ if (websocket !== null) {
+ websocket.send(JSON.stringify(event))
+ }
+ }
+
+ requestAnimationFrame() {
+ // Request an animation frame.
+ // Before the anywidget refactor, we did this via a tiny delay, which supposedly made things more smooth,
+ // but it also increases the delay for a frame to hit the screen, and limits the max fps, so let's not do that.
+ if (!this.imgUpdatePending) {
+ this.imgUpdatePending = true
+ window.requestAnimationFrame(this.animate.bind(this))
+ }
+ }
+
+ animate() {
+ this.imgUpdatePending = false
+ if (this.frames.length === 0) { return };
+
+ // Pick the oldest frame from the stack, and get its source
+ const frame = this.frames.shift()
+ let newSrc
+ if (frame.buffers && frame.buffers.length > 0) {
+ const blob = new Blob([frame.buffers[0]], { type: frame.mimetype })
+ newSrc = URL.createObjectURL(blob)
+ } else {
+ newSrc = frame.data_b64
+ }
+
+ // Revoke last objectURL
+ URL.revokeObjectURL(this.lastSrc)
+ this.lastSrc = newSrc
+
+ // Update the image sources
+ view.viewElement.src = newSrc
+ view.viewElement.onload = this.requestAnimationFrame.bind(this)
+
+ // Let the server know we processed the image (even if it's not shown yet)
+ this.sendResponse(frame)
+ }
+
+ sendResponse(frame) {
+ // Let Python know what we have at the frame.
+ const event = { type: '_framefeedback', index: frame.index, timestamp: frame.timestamp, localtime: Date.now() / 1000 }
+ this.onEvent(event)
+ }
+}
+
+function updateStatus() {
+ if (statusElement === null) { return }
+
+ let activeText = ''
+ if (isActive !== null) {
+ activeText = isActive ? ' (active)' : '(passive)'
+ }
+
+ if (websocket === null) {
+ statusElement.innerHTML = "? Disconnected reconnect "
+ } else {
+ statusElement.innerHTML = `+ Connected ${activeText}`
+ }
+}
+
+function openWebsocketConnection() {
+ const ws = new WebSocket('ws://' + window.location.host + window.location.pathname)
+
+ ws.onopen = (e) => {
+ console.log('websocket opened')
+ websocket = ws
+ window.websocket = ws // allow manual closing to mimic lost connection
+ if (view === null) {
+ view = new ClientRenderView(wrapperElement)
+ console.log('created ClientRenderView')
+ }
+ updateStatus()
+ }
+ ws.onerror = (e) => {
+ console.log(`websocket error: ${e}`)
+ websocket = null
+ updateStatus()
+ }
+
+ let pendingMsg
+ ws.onmessage = (e) => {
+ let msg = null
+
+ // First some handling to support a message with buffers
+ if (typeof e.data === 'string' || e.data instanceof String) {
+ msg = JSON.parse(e.data)
+ if (msg.nbuffers && msg.nbuffers > 0) {
+ pendingMsg = msg
+ pendingMsg.buffers = []
+ msg = null
+ } else {
+ pendingMsg = null // discard unfinished pending message (if any)
+ }
+ } else { // Blob
+ if (pendingMsg !== null) {
+ pendingMsg.buffers.push(e.data)
+ if (pendingMsg.buffers.length >= pendingMsg.nbuffers) {
+ msg = pendingMsg
+ pendingMsg = null
+ }
+ }
+ }
+
+ if (msg === null) { return }
+
+ // Process message
+ // console.log(msg)
+ if (msg.type === 'framebufferdata') {
+ view.frames.push(msg)
+ view.requestAnimationFrame()
+ } else if (msg.type === 'active') {
+ isActive = msg.value
+ updateStatus()
+ } else if (msg.type === 'cursor') {
+ view.setCursor(msg.value)
+ }
+ }
+
+ ws.onclose = (e) => {
+ console.log(`websocket closed: ${e.reason} (${e.code})`)
+ websocket = null
+ updateStatus()
+ }
+}
diff --git a/rendercanvas/http.py b/rendercanvas/http.py
new file mode 100644
index 0000000..33e2f42
--- /dev/null
+++ b/rendercanvas/http.py
@@ -0,0 +1,621 @@
+"""
+A remote backend with one or more browser views.
+
+This module implements an ASGI web application, so it runs on any ASGI server. We default to uvicorn.
+"""
+
+__all__ = ["HttpRenderCanvas", "RenderCanvas", "asgi", "loop"]
+
+import json
+import time
+import asyncio
+from importlib.resources import files as resource_files
+
+from .base import BaseCanvasGroup, BaseRenderCanvas, logger
+from .asyncio import AsyncioLoop
+from .core.encoders import encode_array, CAN_JPEG
+from .core.events import valid_event_types
+
+import numpy as np
+
+
+HTML = """
+
+
+ RenderCanvas over http
+
+
+
+
+
+ RenderCanvas over http
+
+
+
+
+
+
+"""
+
+
+def _load_resource(fname):
+ return resource_files("rendercanvas.core").joinpath(fname).read_text()
+
+
+# A dict with resources to serve. It maps path -> (content-type, body)
+resources = {}
+resources["index.html"] = "text/html", HTML
+resources["renderview.css"] = "text/css", _load_resource("renderview.css")
+for fname in ("renderview.js", "renderview-client.js"):
+ resources[fname] = "text/javascript", _load_resource(fname)
+
+
+class Websocket:
+ """An ASGI websocket
+
+ Each websocket represents one view. These could be in the same browser
+ window, or in different continents.
+ """
+
+ def __init__(self, app, id):
+ self._app = app
+ self._id = id
+ self._send_queue = asyncio.Queue()
+
+ async def _websocket_receiver(self, receive):
+ try:
+ while True:
+ event = await receive() # asgi event
+ if event["type"] == "websocket.receive":
+ if "text" in event:
+ self._on_receive(event["text"])
+ elif "bytes" in event:
+ self._on_receive(event["bytes"])
+ elif event["type"] == "websocket.disconnect":
+ break
+ except asyncio.CancelledError:
+ pass
+
+ async def _websocket_sender(self, send):
+ try:
+ while True:
+ msg = await self._send_queue.get()
+ if msg is None:
+ await send({"type": "websocket.close", "code": 1000})
+ break
+ elif isinstance(msg, str):
+ await send({"type": "websocket.send", "text": msg})
+ else:
+ await send({"type": "websocket.send", "bytes": msg})
+ except asyncio.CancelledError:
+ pass
+ except Exception as err:
+ if "disconnect" not in err.__class__.__name__.lower():
+ raise err from None
+
+ def _on_receive(self, text_or_bytes: str | bytes):
+ if isinstance(text_or_bytes, bytes):
+ logger.warning("Unexpectedly received bytes ({len(msg}).")
+ else:
+ text = text_or_bytes
+ try:
+ event = json.loads(text) # JS event
+ except Exception:
+ short_text = text[:100] + "…" if len(text) > 100 else text
+ logger.warning(f"Received non-json message: {short_text!r}")
+ return
+ else:
+ self._app._on_event(event, self._id)
+
+ def send(self, data: dict | bytes):
+ """Send data into the websocket."""
+ if isinstance(data, dict):
+ data = json.dumps(data)
+ elif isinstance(data, bytes):
+ data = data
+ else:
+ RuntimeError("ws.send expects dict or bytes")
+ asyncio.create_task(self._send_queue.put(data)) # noqa: RUF006
+
+ def close(self):
+ """Close the websocket from our end."""
+ _ = self._send_queue.put(None) # None means close, see _websocket_sender()
+
+
+class Asgi:
+ """The ASGI application.
+
+ This is pretty low-level web-server code, but it means we have minimal dependencies.
+
+ One server, one canvas. So can create only one canvas in a process. Unless
+ we can have multiple ASGI apps running simultaneously, e.g. on different ports or paths.
+
+ One websocket for each client. But only first websocket in the list controls.
+ """
+
+ def __init__(self, resources):
+ self._resources = resources
+ self._websockets = {} # id -> ws
+ self._event_callback = lambda ev, id: None
+ self._ws_counter = 0
+
+ async def __call__(self, scope, receive, send):
+ """The ASGI entrypoint."""
+
+ if scope["type"] == "lifespan":
+ while True:
+ message = await receive()
+ if message["type"] == "lifespan.startup":
+ loop.kickstart()
+ await send({"type": "lifespan.startup.complete"})
+ elif message["type"] == "lifespan.shutdown":
+ ... # Do some shutdown here!
+ await send({"type": "lifespan.shutdown.complete"})
+ return
+
+ elif scope["type"] == "http":
+ # Just assume a flat resources dict, so we can mount anywhere in a larger app
+ fname = scope["path"].rsplit("/", 1)[-1]
+ fname = fname or "index.html"
+ content_type_and_body = self._resources.get(fname, None)
+ if content_type_and_body is not None:
+ content_type, body = content_type_and_body
+ if isinstance(body, str):
+ body = body.encode()
+ await send(
+ {
+ "type": "http.response.start",
+ "status": 200,
+ "headers": [(b"content-type", content_type.encode())],
+ }
+ )
+ await send({"type": "http.response.body", "body": body})
+ else:
+ await send(
+ {
+ "type": "http.response.start",
+ "status": 404,
+ "headers": [(b"content-type", b"text/plain")],
+ }
+ )
+ await send({"type": "http.response.body", "body": b"Not Found"})
+
+ elif scope["type"] == "websocket":
+ await send({"type": "websocket.accept"})
+
+ # When running mounted in a larger app, we miss out on the lifespan events
+ loop.kickstart()
+
+ self._ws_counter += 1
+ id = self._ws_counter
+ ws = Websocket(self, id)
+ self._websockets[id] = ws
+ self._event_callback(
+ {"type": "_clients_change", "ids": tuple(self._websockets)}, 0
+ )
+
+ try:
+ receiver = asyncio.create_task(ws._websocket_receiver(receive))
+ sender = asyncio.create_task(ws._websocket_sender(send))
+ _done, pending = await asyncio.wait(
+ [receiver, sender],
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+ for task in pending:
+ task.cancel()
+ finally:
+ self._websockets.pop(id, None)
+ self._event_callback(
+ {"type": "_clients_change", "ids": tuple(self._websockets)}, 0
+ )
+
+ def _on_event(self, event, id):
+ """Called when a websocket receives an event."""
+ try:
+ self._event_callback(event, id)
+ except Exception as err:
+ logger.warning(f"Error handling ws event callback: {err}")
+
+ def send_all(self, msg: dict):
+ """Send data to all websockets."""
+ assert isinstance(msg, dict)
+ for ws in self._websockets.values():
+ ws.send(msg)
+
+ def send_to(self, msg: dict, buffers: list[bytes], ids=list[int]):
+ if len(buffers) > 0:
+ assert msg["nbuffers"] == len(buffers)
+ for id in ids:
+ ws = self._websockets.get(id, None)
+ if ws is not None:
+ ws.send(msg)
+ for buffer in buffers:
+ ws.send(buffer)
+
+ def close(self):
+ """Disconnect all clients."""
+ # I guess technically clients can reconnect again. Not sure if that works.
+ for ws in self._websockets.values():
+ ws.close()
+
+ def get_count(self):
+ return len(self._websockets)
+
+
+class HttpLoop(AsyncioLoop):
+ def run(self, host="localhost", port=60649):
+ self._host = host
+ self._port = port
+ return super().run()
+
+ def _rc_run(self):
+ # Allow the standard rendercanvas usage (``loop.run()``) to start the web server
+
+ from uvicorn.main import main as uvicorn_main
+
+ # Use warning level; if using info, the message may not be shown
+ logger.warning(f"Starting server at http://{self._host}:{self._port}")
+ uvicorn_main(
+ [
+ f"--host={self._host}",
+ f"--port={self._port}",
+ "--log-level=warning",
+ f"{__name__}:asgi",
+ ]
+ )
+
+ def kickstart(self):
+ if self._run_loop is None:
+ try:
+ asyncio.get_running_loop().create_task(loop._rc_run_async())
+ except Exception as err:
+ logger.error("could not start rendercanvas loop:", err)
+ else:
+ logger.info("rendercanvas loop started")
+
+
+loop = HttpLoop()
+
+
+class HttpCanvasGroup(BaseCanvasGroup):
+ pass
+
+
+class HttpRenderCanvas(BaseRenderCanvas):
+ """A remote canvas that is served over http and viewed in a browser."""
+
+ _rc_canvas_group = HttpCanvasGroup(loop)
+
+ _max_buffered_frames = 2
+
+ _quality = 80
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Note: we assume there is only a sinle canvas
+ asgi._event_callback = self._on_event
+
+ self._is_closed = False
+
+ self._draw_requested = False
+ self._frame_feedback = {}
+ self._frame_index = 0
+ self._last_confirmed_index = 0
+ self._warned_png = False
+ self._lossless_draw_info = None
+
+ self._active_client = 0
+ self._confirmed_frame_per_client = {}
+
+ self.reset_stats()
+
+ # Set size, title, etc.
+ self._final_canvas_init()
+
+ def _on_event(self, event: dict, id: int):
+ try:
+ type = event["type"]
+ except KeyError:
+ logger.warning(f"Invalid event: {event!r}")
+ return
+
+ if type.startswith("_"):
+ # Internal event
+ if type == "_clients_change":
+ # Update our per-client info
+ self._confirmed_frame_per_client = {
+ id: self._confirmed_frame_per_client.get(id, 0)
+ for id in event["ids"]
+ }
+ print(self._confirmed_frame_per_client)
+ # select longest connected client as the new active one
+ self._active_client = event["ids"][0] if event["ids"] else 0
+ self._update_active_states()
+ # Force a draw. With a new client, we want to override frame feedback
+ self._draw_requested = 2
+ loop.call_soon(self._maybe_draw)
+ elif type == "_framefeedback":
+ # Update last confirmed frame. But only schedule new draws based on the active client.
+ self._confirmed_frame_per_client[id] = event["index"]
+ if id == self._active_client:
+ self._frame_feedback = event
+ loop.call_soon(self._maybe_draw)
+ else:
+ logger.warning(f"Unknown event: {event!r}")
+ else:
+ # A renderview event
+ if id != self._active_client:
+ return # ignore events from passive clients
+
+ if type == "resize":
+ self._size_info.set_physical_size(
+ event["pwidth"], event["pheight"], event["ratio"]
+ )
+ elif type == "close":
+ self.close()
+ elif type in valid_event_types:
+ # Compatibility between new renderview event spec and current rendercanvas/pygfx events
+ event["event_type"] = event.pop("type")
+ event["time_stamp"] = event.pop("timestamp")
+ # Turn lists into tuples (js/json does not have tuples)
+ if "buttons" in event:
+ event["buttons"] = tuple(event["buttons"])
+ if "modifiers" in event:
+ event["modifiers"] = tuple(event["modifiers"])
+ self.submit_event(event)
+
+ def _maybe_draw(self):
+ """Perform a draw, if we can and should."""
+ # TODO: restore _schedule_maybe_draw? bc there's quite a lot of invocations with multiple clients
+ feedback = self._frame_feedback
+ # Update stats
+ self._update_stats(feedback)
+ # Determine frames in flight for all clients, allowing 2 more in-flight
+ need_index = self._frame_index - self._max_buffered_frames + 1 - 2
+ all_clients_ok = all(
+ index == 0 or index >= need_index
+ for index in self._confirmed_frame_per_client.values()
+ )
+ # Determine whether we should perform a draw: a draw was requested, and
+ # the client is ready for a new frame, and the client widget is visible.
+ frames_in_flight = self._frame_index - feedback.get("index", 0)
+ should_draw = (
+ self._draw_requested
+ and all_clients_ok
+ and (
+ frames_in_flight < self._max_buffered_frames
+ or self._draw_requested >= 2
+ )
+ and asgi.get_count() > 0
+ )
+ # Do the draw if we should.
+ if should_draw:
+ self._draw_requested = False
+ self._time_to_draw() # -> _rc_present_bitmap -> _send_frame
+
+ def _schedule_lossless_draw(self, array, delay=0.3):
+ self._cancel_lossless_draw()
+ loop = asyncio.get_running_loop()
+ handle = loop.call_later(delay, self._lossless_draw)
+ self._lossless_draw_info = array, handle
+
+ def _cancel_lossless_draw(self):
+ if self._lossless_draw_info:
+ _, handle = self._lossless_draw_info
+ self._lossless_draw_info = None
+ handle.cancel()
+
+ def _lossless_draw(self):
+ array, _ = self._lossless_draw_info
+ self._send_frame(array, True)
+
+ def _send_frame(self, array, is_lossless_redraw=False):
+ """Actually send a frame over to the client."""
+ # For considerations about performance,
+ # see https://github.com/vispy/jupyter_rfb/issues/3
+ quality = 100 if is_lossless_redraw else self._quality
+
+ self._frame_index += 1
+ timestamp = time.time()
+
+ # Turn array into a based64-encoded JPEG or PNG
+ t1 = time.perf_counter()
+ mimetype, data = encode_array(array, quality)
+ if True: # use_websocket:
+ buffers = [data]
+ data_b64 = None
+ else:
+ buffers = []
+ from base64 import encodebytes
+
+ data_b64 = f"data:{mimetype};base64," + encodebytes(data).decode()
+ t2 = time.perf_counter()
+
+ if "jpeg" in mimetype:
+ self._schedule_lossless_draw(array)
+ else:
+ self._cancel_lossless_draw()
+ # Issue png warning?
+ if quality < 100 and not CAN_JPEG and not self._warned_png:
+ self._warned_png = True
+ logger.warning(
+ "No JPEG encoder found, using PNG instead. Install simplejpeg for better performance."
+ )
+
+ if is_lossless_redraw:
+ # No stats, also not on the confirmation of this frame
+ self._last_confirmed_index = self._frame_index
+ else:
+ # Stats
+ self._stats["img_encoding_sum"] += t2 - t1
+ self._stats["sent_frames"] += 1
+ if self._stats["start_time"] <= 0: # Start measuring
+ self._stats["start_time"] = timestamp
+ self._last_confirmed_index = self._frame_index - 1
+
+ # Compose message and send
+ msg = dict(
+ type="framebufferdata",
+ nbuffers=len(buffers),
+ mimetype=mimetype,
+ data_b64=data_b64,
+ index=self._frame_index,
+ timestamp=timestamp,
+ )
+
+ # Which client receive this?
+ ids = list(self._confirmed_frame_per_client.keys())
+
+ # Currently we send each frame to all clients, and also throttle on the slowest
+ # client (though allowing more in-flight frames for passive clients). This means
+ # that one slow client means a low framerate for all clients. Alternatively, we
+ # can throttle on the active client, and drop frames for the passive clients
+ # that are not up-to-date enough. The commented code below does that. It is not
+ # fully functional, bc the frames_in_flight is over-estimated bc we drop frames
+ # ;) In any case, when/if we use diff images each client MUST have each frame.
+ # Therefore I kept the simpler throttle-on-all approach.
+ #
+ # ids = [self._active_client]
+ # for id, confirmed_index in self._confirmed_frame_per_client.items():
+ # if id != self._active_client:
+ # # Add id if it has not too many frames in flight. Subtract 1 bc we just bumped self._frame_index. Allow 1 extra in-flight frame for passive clients.
+ # frames_in_flight = self._frame_index - confirmed_index - 1
+ # if frames_in_flight < self._max_buffered_frames + 1:
+ # ids.append(id)
+ # elif confirmed_index == 0:
+ # ids.append(id) # new client
+
+ asgi.send_to(msg, buffers, ids)
+
+ # ----- related to stats
+
+ def reset_stats(self):
+ """Restart measuring statistics from the next sent frame."""
+ self._stats = {
+ "start_time": 0,
+ "last_time": 1,
+ "sent_frames": 0,
+ "confirmed_frames": 0,
+ "roundtrip_count": 0,
+ "roundtrip_sum": 0,
+ "delivery_sum": 0,
+ "img_encoding_sum": 0,
+ }
+
+ def get_stats(self):
+ """Get the current stats since the last time ``.reset_stats()`` was called.
+
+ Stats is a dict with the following fields:
+
+ * *sent_frames*: the number of frames sent.
+ * *confirmed_frames*: number of frames confirmed by the client.
+ * *roundtrip*: average time for processing a frame, including receiver confirmation.
+ * *delivery*: average time for processing a frame until it's received by the client.
+ This measure assumes that the clock of the server and client are precisely synced.
+ * *img_encoding*: the average time spent on encoding the array into an image.
+ * *b64_encoding*: the average time spent on base64 encoding the data.
+ * *fps*: the average FPS, measured from the first frame sent since ``.reset_stats()``
+ was called, until the last confirmed frame.
+ """
+ d = self._stats
+ roundtrip_count_div = d["roundtrip_count"] or 1
+ sent_frames_div = d["sent_frames"] or 1
+ fps_div = (d["last_time"] - d["start_time"]) or 0.001
+ return {
+ "sent_frames": d["sent_frames"],
+ "confirmed_frames": d["confirmed_frames"],
+ "roundtrip": d["roundtrip_sum"] / roundtrip_count_div,
+ "delivery": d["delivery_sum"] / roundtrip_count_div,
+ "img_encoding": d["img_encoding_sum"] / sent_frames_div,
+ "fps": d["confirmed_frames"] / fps_div,
+ }
+
+ def _update_stats(self, feedback):
+ """Update the stats when a new frame feedback has arrived."""
+ last_index = feedback.get("index", 0)
+ if last_index > self._last_confirmed_index:
+ timestamp = feedback["timestamp"]
+ nframes = last_index - self._last_confirmed_index
+ self._last_confirmed_index = last_index
+ self._stats["confirmed_frames"] += nframes
+ self._stats["roundtrip_count"] += 1
+ self._stats["roundtrip_sum"] += time.time() - timestamp
+ self._stats["delivery_sum"] += feedback["localtime"] - timestamp
+ self._stats["last_time"] = time.time()
+
+ # --- the API to be a rendercanvas backend
+
+ def _rc_gui_poll(self):
+ pass
+
+ def _rc_get_present_info(self, present_methods):
+ # Only allow simple format for now. srgb is assumed.
+ if "bitmap" in present_methods:
+ return {
+ "method": "bitmap",
+ "formats": ["rgba-u8"],
+ }
+ else:
+ return None # raises error
+
+ def _rc_request_draw(self):
+ # Technically, _maybe_draw() may not perform a draw if there are too
+ # many frames in-flight. But in this case, we'll eventually get
+ # new frame_feedback, which will then trigger a draw.
+ if not self._draw_requested:
+ self._draw_requested = True
+ self._cancel_lossless_draw()
+ loop.call_soon(self._maybe_draw)
+
+ def _rc_request_paint(self):
+ # We technically don't need to call _time_to_paint, because this backend only does bitmap mode.
+ # But in case the base backend will do something in _time_to_paint later, we behave nice.
+ loop = self._rc_canvas_group.get_loop()
+ loop.call_soon(self._time_to_paint)
+
+ def _rc_force_paint(self):
+ pass # works as-is via push_frame
+
+ def _rc_present_bitmap(self, *, data, format, **kwargs):
+ assert format == "rgba-u8"
+ self._send_frame(np.asarray(data))
+
+ def _rc_set_logical_size(self, width, height):
+ asgi.send_all({"type": "comm-css-width", "value": f"{width}px"})
+ asgi.send_all({"type": "comm-css-height", "value": f"{height}px"})
+
+ def _rc_close(self):
+ asgi.close()
+ self._is_closed = True
+
+ def _rc_get_closed(self):
+ return self._is_closed
+
+ def _rc_set_title(self, title):
+ asgi.send_all({"type": "title", "value": title})
+
+ def _rc_set_cursor(self, cursor):
+ asgi.send_all({"type": "cursor", "value": cursor})
+
+ def set_css_width(self, css_width: str):
+ """Set the width of the canvas as a CSS string."""
+ asgi.send_all({"type": "css_width", "value": css_width})
+
+ def set_css_height(self, css_height: str):
+ """Set the height of the canvas as a CSS string."""
+ asgi.send_all({"type": "css_height", "value": css_height})
+
+ def _update_active_states(self):
+ active_ids = [self._active_client]
+ passive_ids = set(self._confirmed_frame_per_client.keys())
+ passive_ids.discard(self._active_client)
+
+ asgi.send_to({"type": "active", "value": True}, [], active_ids)
+ asgi.send_to({"type": "active", "value": False}, [], passive_ids)
+
+
+asgi = Asgi(resources)
+RenderCanvas = HttpRenderCanvas
diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py
index 66c954e..e3ec819 100644
--- a/rendercanvas/utils/asyncs.py
+++ b/rendercanvas/utils/asyncs.py
@@ -38,6 +38,8 @@ def detect_current_async_lib():
libname = "rendercanvas.utils.asyncadapter"
elif libname == "pyodide":
libname = "asyncio"
+ elif libname == "uvloop":
+ libname = "asyncio"
return libname