Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
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
174 changes: 174 additions & 0 deletions examples/html_canvas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import rendercanvas
print("rendercanvas version:", rendercanvas.__version__)
from rendercanvas.base import BaseRenderCanvas, BaseCanvasGroup, BaseLoop

Check failure on line 3 in examples/html_canvas.py

View workflow job for this annotation

GitHub Actions / Linting

Ruff (E402)

examples/html_canvas.py:3:1: E402 Module level import not at top of file

import numpy as np

Check failure on line 5 in examples/html_canvas.py

View workflow job for this annotation

GitHub Actions / Linting

Ruff (E402)

examples/html_canvas.py:5:1: E402 Module level import not at top of file

# packages available inside pyodide
from pyodide.ffi import run_sync

Check failure on line 8 in examples/html_canvas.py

View workflow job for this annotation

GitHub Actions / Linting

Ruff (E402)

examples/html_canvas.py:8:1: E402 Module level import not at top of file
from js import document, ImageData, Uint8ClampedArray, window

Check failure on line 9 in examples/html_canvas.py

View workflow job for this annotation

GitHub Actions / Linting

Ruff (E402)

examples/html_canvas.py:9:1: E402 Module level import not at top of file
# import sys
# assert sys.platform == "emscripten" # use in the future to direct the auto backend?

# TODO event loop for js? https://rendercanvas.readthedocs.io/stable/backendapi.html#rendercanvas.stub.StubLoop
# https://pyodide.org/en/stable/usage/sdl.html#working-with-infinite-loop
# https://pyodide.org/en/stable/usage/api/python-api/webloop.html
# https://github.com/pyodide/pyodide/blob/0.28.2/src/py/pyodide/webloop.py
# also the asyncio.py implementation
class HTMLLoop(BaseLoop):
def __init__(self):
super().__init__()
self._webloop = None
self.__pending_tasks = []
self._stop_event = None

def _rc_init(self):
from pyodide.webloop import WebLoop
import asyncio

Check failure on line 27 in examples/html_canvas.py

View workflow job for this annotation

GitHub Actions / Linting

Ruff (F401)

examples/html_canvas.py:27:16: F401 `asyncio` imported but unused
self._webloop = WebLoop()

# TODO later try this
# try:
# self._interactive_loop = self._webloop.get_running_loop()
# self._stop_event = PyodideFuture()
# self._mark_as_interactive()
# except Exception:
# self._interactive_loop = None
self._interactive_loop = None

def _rc_run(self):
import asyncio #so the .run method is now overwritten I guess
if self._interactive_loop is not None:
return
# self._webloop.run_forever() # or untill stop event?
asyncio.run(self._rc_run_async())

async def _rc_run_async(self):
import asyncio
self._run_loop = self._webloop

while self.__pending_tasks:
self._rc_add_task(*self.__pending_tasks.pop(-1))

if self._stop_event is None:
self._stop_event = asyncio.Event()
await self._stop_event.wait()

# untested maybe...
def _rc_stop_(self):
while self.__tasks:
task = self.__tasks.pop()
task.cancel()

self._stop_event.set()
self._stop_event = None
self._run_loop = None

def _rc_call_later(self, delay, callback, *args):
self._webloop.call_later(delay, callback, *args)

pyodide_loop = HTMLLoop()

# needed for completeness? somehow is required for other examples - hmm?
class HTMLCanvasGroup(BaseCanvasGroup):
pass

# TODO: make this a proper RenderCanvas, just a poc for now
# https://rendercanvas.readthedocs.io/stable/backendapi.html#rendercanvas.stub.StubRenderCanvas
class HTMLBitmapCanvas(BaseRenderCanvas):
_rc_canvas_group = HTMLCanvasGroup(pyodide_loop) # todo do we need the group?
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
canvas_element = document.getElementById("canvas")
self.canvas_element = canvas_element
self.context = canvas_element.getContext("bitmaprenderer")

self._final_canvas_init()

def _rc_gui_poll(self):
# not sure if anything has to be done
pass

def _rc_get_present_methods(self):
# in the future maybe we can get the webgpu context (as JsProxy) or something... future stuff!
return {
"bitmap": {
"formats": ["rgba-u8"],
}
}

def _rc_request_draw(self):
# loop.call_soon?
loop = self._rc_canvas_group.get_loop()
loop.call_soon(self._draw_frame_and_present)
# window.requestAnimationFrame(self._rc_present_bitmap) #doesn't feel like this is the way... maybe more reading
# self._rc_force_draw()
# print("request draw called?")

def _rc_force_draw(self):
self._draw_frame_and_present() # returns without calling the present it seems

def _rc_present_bitmap(self):
# this actually "writes" the data to the canvas I guess.
self.context.transferFromImageBitmap(self._image_bitmap)

# reimplement so we might understand what's going on
def _draw_frame_and_present(self):
self._draw_frame()
self._rc_present_bitmap()

def _rc_get_physical_size(self):
return self.canvas_element.style.width, self.canvas_element.style.height

def _rc_get_logical_size(self):
return float(self.canvas_element.width), float(self.canvas_element.height)

def _rc_get_pixel_ratio(self) -> float:
ratio = window.devicePixelRatio
return ratio

def _rc_set_logical_size(self, width: float, height: float):
ratio = self._rc_get_pixel_ratio()
self.canvas_element.width = int(width * ratio) # only positive, int() -> floor()
self.canvas_element.height = int(height * ratio)
# also set the physical scale here?
# self.canvas_element.style.width = f"{width}px"
# self.canvas_element.style.height = f"{height}px"

def set_bitmap(self, bitmap):
# TODO: improve performance https://pyodide.org/en/stable/usage/type-conversions.html#buffers
# TODO: avoid memory leak!!!
# doesn't really exist? as it's part of the context? maybe we move it into the draw function...
self._last_bitmap = bitmap # keep track?
h, w, _ = bitmap.shape
flat_bitmap = bitmap.flatten()
js_array = Uint8ClampedArray.new(flat_bitmap.tolist())
image_data = ImageData.new(js_array, w, h)
# now this is the fake async call so it should be blocking
self._image_bitmap = run_sync(window.createImageBitmap(image_data))

def _rc_close(self):
# self.canvas_element.remove() # shouldn't really be needed?
pass

def _rc_get_closed(self):
# TODO: like check if the element still exists?
return False

def _rc_set_title(self, title: str):
# canvas element doens't have a title directly... but maybe the whole page?
document.title = title

# TODO: events


canvas = HTMLBitmapCanvas(title="RenderCanvas in Pyodide", max_fps=3.0)
def animate():
# based on the noise.py example
w, h = canvas._rc_get_logical_size()
shape = (int(h), int(w), 4) # third dimension sounds like it's needed
bitmap = np.random.uniform(0, 255, shape).astype(np.uint8)
canvas.set_bitmap(bitmap)

canvas.request_draw(animate)
pyodide_loop.run()
33 changes: 33 additions & 0 deletions examples/local_browser.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!-- adapted from: https://traineq.org/imgui_bundle_online/projects/min_bundle_pyodide_app/demo_heart.source.txt -->
<!doctype html>
<html>
<head>
RenderCanvas HTML canvas via Pyodide <br>
<script src="https://cdn.jsdelivr.net/pyodide/v0.28.2/full/pyodide.js"></script>
</head>
<body>
<canvas id="canvas" width="640" height="480"></canvas>
some text below the canvas!
<script type="text/javascript">
async function main(){

// fetch the file locally for easier scripting
// --allow-file-access-from-files or local webserver
// TODO: replace the actual code here (unless you have the module)
pythonCode = await (await fetch("html_canvas.py")).text();

// Load Pyodide
let pyodide = await loadPyodide();

await pyodide.loadPackage("micropip");
const micropip = pyodide.pyimport("micropip");
await micropip.install('numpy');
await micropip.install('rendercanvas');

// Run the Python code async because some calls are async it seems.
pyodide.runPythonAsync(pythonCode);
}
main();
</script>
</body>
</html>
2 changes: 1 addition & 1 deletion rendercanvas/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,7 @@ def _rc_get_pixel_ratio(self) -> float:
raise NotImplementedError()

def _rc_set_logical_size(self, width: float, height: float):
"""Set the logical size. May be ignired when it makes no sense.
"""Set the logical size. May be ignored when it makes no sense.

The default implementation does nothing.
"""
Expand Down
Loading