diff --git a/docs/backends.rst b/docs/backends.rst index b648db7..175dd0f 100644 --- a/docs/backends.rst +++ b/docs/backends.rst @@ -41,6 +41,12 @@ The table below gives an overview of the names in the different ``rendercanvas`` | ``RenderCanvas`` (alias) | ``loop`` (a ``StubLoop``) - | For offscreen rendering. + * - ``http`` + - | ``HttpRenderCanvas`` + | ``RenderCanvas`` (alias) + | ``loop`` (an ``AsyncioLoop``) + - | Run an http server that streams the images + | to a view in the browser. * - ``anywidget`` - | ``AnywidgetRenderCanvas`` | ``RenderCanvas`` (alias) @@ -50,13 +56,14 @@ The table below gives an overview of the names in the different ``rendercanvas`` - | ``JupyterRenderCanvas`` | ``RenderCanvas`` (alias) | ``loop`` (an ``AsyncioLoop``) - - | Integrate in notebooks via ``jupyter_rfb`` (deprecated). + - | Integrate in notebooks via ``jupyter_rfb`` + | (deprecated). * - ``pyodide`` - | ``PyodideRenderCanvas`` (toplevel) | ``RenderCanvas`` (alias) | ``loop`` (an ``AsyncioLoop``) - - | Backend when Python is running in the browser, - | via Pyodide or PyScript. + - | Backend when Python is running in the + | browser via Pyodide or PyScript. There are also three loop-backends. These are mainly intended for use with the glfw backend: @@ -272,6 +279,47 @@ object, but in some cases it's convenient to do so with a canvas-like API. array = canvas.draw() # numpy array with shape (400, 500, 4) +Support for http / web (experimental) +------------------------------------- + +The http backend can be used to open a canvas in a browser, streaming the images over a websocket. + +.. autoclass:: rendercanvas.http.HttpRenderCanvas + :members: + +It can be enabled simply by selecting the http backend: + +.. code-block:: py + + from rendercanvas.http import RenderCanvas + + ... your normal code as usual + +The only dependency is ``uvicorn``, a low-level ASGI server. +It is also possible to customize the server's resources, see the ``cube_http.py`` example for details: + +.. code-block:: py + + from rendercanvas.http import RenderCanvas, loop, resources + + resources["index.html"] = "text/html", your custom html + resources["logo.png"] = "image/png", encode_png(numpy_array) + +Finally, you can embed it in a larger ASGI application. This requires the framework to support mounting an ASGI application: + +.. code-block:: py + + from fastapi import FastAPI + from rendercanvas.http import RenderCanvas, asgi + + app = FastAPI() + + ... + + # Attach the rendercanvas part at a sub path + app.mount("/rendercanvas_path", asgi) + + Support for notebooks --------------------- diff --git a/examples/cube_http.py b/examples/cube_http.py new file mode 100644 index 0000000..d83c52e --- /dev/null +++ b/examples/cube_http.py @@ -0,0 +1,60 @@ +""" +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. +# The args are optional and default to "localhost" and port 60649 +loop.run("localhost", 8080) 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..7acf9eb --- /dev/null +++ b/rendercanvas/core/renderview-client.js @@ -0,0 +1,176 @@ +/************************************************************************************************* + 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 " + } 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) + } else if (msg.type === 'title') { + view.setTitle(msg.value) + } else if (msg.type === 'css_width') { + view.setCssWidth(msg.value) + } else if (msg.type === 'css_height') { + view.setCssHeight(msg.value) + } + } + + ws.onclose = (e) => { + console.log(`websocket closed: ${e.reason} (${e.code})`) + websocket = null + updateStatus() + } +} diff --git a/rendercanvas/core/renderview.js b/rendercanvas/core/renderview.js index 2348a16..f8e27b1 100644 --- a/rendercanvas/core/renderview.js +++ b/rendercanvas/core/renderview.js @@ -596,7 +596,7 @@ class BaseRenderView { viewElement.addEventListener('pointerdown', (ev) => { // When pointer is down, set focus to the focus-element. if (!LOOKS_LIKE_MOBILE) { - this._focusElement.focus({ preventScroll: true, focusVisble: false }) + this._focusElement.focus({ preventScroll: true, focusVisible: false }) } // capture the pointing device. // Because we capture the event, there will be no other events when buttons are pressed down, @@ -917,7 +917,7 @@ class BaseRenderView { } // Prevent the text box from growing if (!ev.isComposing) { - this.focus_el.value = '' + this._focusElement.value = '' } const event = { diff --git a/rendercanvas/http.py b/rendercanvas/http.py new file mode 100644 index 0000000..a0e7b23 --- /dev/null +++ b/rendercanvas/http.py @@ -0,0 +1,611 @@ +""" +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

+ +
+

Loading ...

+
+ +
+ + +""" + + +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" in err.__class__.__name__.lower(): + pass + elif "websocket.close" in err.args[0]: + pass + else: + 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], id=int): + if len(buffers) > 0: + assert msg["nbuffers"] == len(buffers) + 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): + """An experimental remote canvas that is served over http and viewed in a browser. + + It is assumed that there is exactly one canvas in the application. + + Multiple clients can simultaneously connect to the server. They will be + served the same stream of images. There is one "active" client, which + determines the pace of rendering. When the tab for that client is hidden, + the animation stalls. Events from the passive clients are ignored. Passive + clients drop frames if necessary to keep up with the active client. + When the active client drops, the first passive client becomes active. + + It is the responsibility of the user to handle authorization of remotely + connected clients. It is recommended to use an authentication system and/or + magic links. On localhost this is not an issue though. + """ + + _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 single canvas on the page + asgi._event_callback = self._on_event + + self._is_closed = False + self._draw_requested = False + self._pending_maybe_draw = False + + self._last_frame = None, None + self._ref_index = 0 # global index to identify a frame + self._active_client = 0 + self._frame_info_per_client = {} # {sent: int, confirmed: int, ref: int} + + self._warned_png = False + self._lossless_draw_info = None + + 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 + new = {"sent": 0, "confirmed": 0, "ref": 0} + self._frame_info_per_client = { + id: self._frame_info_per_client.get(id, new) for id in event["ids"] + } + # select longest connected client as the new active one + self._active_client = event["ids"][0] if event["ids"] else 0 + self._update_active_states() + self._schedule_maybe_draw() + elif type == "_framefeedback": + # Update last confirmed frame. But only schedule new draws based on the active client. + self._frame_info_per_client[id]["confirmed"] = event["index"] + self._schedule_maybe_draw() + if id == self._active_client: + self._update_stats_on_frame_receive(event["timestamp"]) + else: + logger.warning(f"Unknown event: {event!r}") + else: + # A renderview event + + # if type == "visible" + 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 _schedule_maybe_draw(self, *args): + if not self._pending_maybe_draw: + self._pending_maybe_draw = True + loop.call_soon(self._maybe_draw) + + def _maybe_draw(self): + """Perform a draw, if we can and should.""" + self._pending_maybe_draw = False + + # Based on the active client, determine if we need a new draw + try: + info = self._frame_info_per_client[self._active_client] + except KeyError: + active_client_ready_for_new_frame = False + else: + active_client_ready_for_new_frame = ( + info["sent"] - info["confirmed"] < self._max_buffered_frames + ) + should_draw = ( + self._draw_requested + and active_client_ready_for_new_frame + and len(self._frame_info_per_client) > 0 + ) + # Do the draw if we should. Otherwise maybe send frames to other clients. + if should_draw: + self._draw_requested = False + self._time_to_draw() # -> _rc_present_bitmap -> _encode_frame + elif len(self._frame_info_per_client) > 1: + self._send_last_frame_to_ready_clients() + + 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): + if self._lossless_draw_info is not None: + array, _ = self._lossless_draw_info + self._encode_frame(array, True) + + def _encode_frame(self, array, is_lossless_redraw=False): + """Encode a frame and store it so we can send it to each client in the right time.""" + # For considerations about performance, + # see https://github.com/vispy/jupyter_rfb/issues/3 + quality = 100 if is_lossless_redraw else self._quality + + timestamp = time.time() + + # Turn array into a based64-encoded JPEG or PNG + t1 = time.perf_counter() + mimetype, data = encode_array(array, quality) + buffers = [data] + 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." + ) + + # Stats + if not is_lossless_redraw: + self._stats["encoding_sum"] += t2 - t1 + self._stats["encoded_frames"] += 1 + if self._stats["start_time"] <= 0: # Start measuring + self._stats["start_time"] = timestamp + + # Compose message and send + msg = dict( + type="framebufferdata", + nbuffers=len(buffers), + mimetype=mimetype, + timestamp=timestamp, + index=0, + ) + + # Store this frame + self._last_frame = msg, buffers + self._ref_index += 1 + + # Send to clients that are ready + self._send_last_frame_to_ready_clients() + + def _send_last_frame_to_ready_clients(self): + """Send the last frame to clients that are ready, and have not yet been sent it.""" + msg, buffers = self._last_frame + max_buffered_frames = self._max_buffered_frames + ref_index = self._ref_index + + for id, info in self._frame_info_per_client.items(): + if info["sent"] - info["confirmed"] < max_buffered_frames: + if info["ref"] < ref_index: + this_msg = msg.copy() + info["ref"] = ref_index + info["sent"] += 1 + this_msg["index"] = info["sent"] + self._stats["sent_frames"] += 1 + asgi.send_to(this_msg, buffers, id) + + # ----- related to stats + + def reset_stats(self): + """Restart measuring statistics from the next sent frame.""" + self._stats = { + "start_time": 0.0, + "last_time": 0.0, + "encoded_frames": 0, + "sent_frames": 0, + "confirmed_frames": 0, + "encoding_sum": 0.0, + "roundtrip_sum": 0.0, + } + + def _update_stats_on_frame_receive(self, timestamp): + """Update the stats when a new frame feedback has arrived.""" + now = time.time() + self._stats["confirmed_frames"] += 1 + self._stats["roundtrip_sum"] += now - timestamp + self._stats["last_time"] = now + + def get_stats(self): + """Get the current stats since the last time ``.reset_stats()`` was called. + + Stats is a dict with the following fields: + + * *encoded_frames*: the number of encoded frames. + * *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. + * *encoding*: the average time spent on encoding the array into an image. + * *fps*: the average FPS, measured from the first frame sent since ``.reset_stats()`` + was called, until the last confirmed frame. + """ + d = self._stats + fps_div = (d["last_time"] - d["start_time"]) or 0.001 + return { + "encoded_frames": d["encoded_frames"], + "sent_frames": d["sent_frames"], + "confirmed_frames": d["confirmed_frames"], + "encoding": d["encoding_sum"] / d["encoded_frames"], + "roundtrip": d["roundtrip_sum"] / d["confirmed_frames"], + "fps": d["confirmed_frames"] / fps_div, + } + + # --- 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() + self._schedule_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._encode_frame(np.asarray(data)) + + def _rc_set_logical_size(self, width, height): + asgi.send_all({"type": "css_width", "value": f"{width}px"}) + asgi.send_all({"type": "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): + for id in self._frame_info_per_client.keys(): + active = id == self._active_client + asgi.send_to({"type": "active", "value": active}, [], id) + + +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 diff --git a/tests/test_backends.py b/tests/test_backends.py index 4d0bb28..31953c1 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -256,6 +256,14 @@ def test_pyodide_module(): assert canvas_class.name == "PyodideRenderCanvas" +def test_http_module(): + m = Module("http") + + canvas_class = m.get_canvas_class() + m.check_canvas(canvas_class) + assert canvas_class.name == "HttpRenderCanvas" + + def test_anywidget_module(): m = Module("anywidget")