From 1feb62da602b8c41cd3ba92fbf0f8696f4858ca8 Mon Sep 17 00:00:00 2001 From: martinRenou Date: Mon, 17 Oct 2022 10:25:32 +0200 Subject: [PATCH 1/6] Implement image data request --- ipycanvas/canvas.py | 13 +++++++++++++ src/widget.ts | 17 +++++++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/ipycanvas/canvas.py b/ipycanvas/canvas.py index 3986458..499e9da 100644 --- a/ipycanvas/canvas.py +++ b/ipycanvas/canvas.py @@ -103,6 +103,7 @@ "strokeStyledPolygons", "strokeStyledLineSegments", "switchCanvas", + "requestImageData", ] COMMANDS = {v: i for i, v in enumerate(_CMD_LIST)} @@ -582,6 +583,8 @@ class Canvas(_CanvasBase): _client_ready_callbacks = Instance(CallbackDispatcher, ()) + _image_data_callbacks = Instance(CallbackDispatcher, ()) + _mouse_move_callbacks = Instance(CallbackDispatcher, ()) _mouse_down_callbacks = Instance(CallbackDispatcher, ()) _mouse_up_callbacks = Instance(CallbackDispatcher, ()) @@ -633,6 +636,11 @@ def sleep(self, time): """Make the Canvas sleep for `time` milliseconds.""" self._canvas_manager.send_draw_command(self, COMMANDS["sleep"], [time]) + def request_image_data(self): + self._canvas_manager.send_draw_command( + self, COMMANDS["requestImageData"] + ) + # Gradient methods def create_linear_gradient(self, x0, y0, x1, y1, color_stops): """Create a LinearGradient object given the start point, end point, and color stops. @@ -1491,6 +1499,9 @@ def on_client_ready(self, callback, remove=False): """ self._client_ready_callbacks.register_callback(callback, remove=remove) + def on_image_data(self, callback, remove=False): + self._image_data_callbacks.register_callback(callback, remove=remove) + def on_mouse_move(self, callback, remove=False): """Register a callback that will be called on mouse move.""" self._mouse_move_callbacks.register_callback(callback, remove=remove) @@ -1542,6 +1553,8 @@ def __setattr__(self, name, value): def _handle_frontend_event(self, _, content, buffers): if content.get("event", "") == "client_ready": self._client_ready_callbacks() + if content.get("event", "") == "image_data": + self._image_data_callbacks(buffers[0]) if content.get("event", "") == "mouse_move": self._mouse_move_callbacks(content["x"], content["y"]) diff --git a/src/widget.ts b/src/widget.ts index c706a3f..5072010 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -127,7 +127,8 @@ const COMMANDS = [ 'fillStyledPolygons', 'strokeStyledPolygons', 'strokeStyledLineSegments', - 'switchCanvas' + 'switchCanvas', + 'requestImageData' ]; export class CanvasManagerModel extends WidgetModel { @@ -190,6 +191,9 @@ export class CanvasManagerModel extends WidgetModel { await this.switchCanvas(args[0]); this.canvasesToUpdate.push(this.currentCanvas); break; + case 'requestImageData': + await this.currentCanvas.sendImageData(); + break; case 'sleep': await this.currentCanvas.sleep(args[0]); break; @@ -634,6 +638,13 @@ export class CanvasModel extends DOMWidgetModel { await new Promise(resolve => setTimeout(resolve, time)); } + async sendImageData(): Promise { + const bytes = await toBytes(this.canvas); + const imageData = serializeImageData(bytes); + + this.send({ event: 'image_data' }, {}, [imageData]); + } + fillRect(x: number, y: number, width: number, height: number) { this.ctx.fillRect(x, y, width, height); } @@ -1493,9 +1504,7 @@ export class MultiCanvasModel extends DOMWidgetModel { ...DOMWidgetModel.serializers, _canvases: { deserialize: unpack_models as any }, image_data: { - serialize: (bytes: Uint8ClampedArray) => { - return new DataView(bytes.buffer.slice(0)); - } + serialize: serializeImageData } }; From e0727cc7ee21175c4b278bd1e782299ef10481e1 Mon Sep 17 00:00:00 2001 From: martinRenou Date: Thu, 20 Oct 2022 13:21:04 +0200 Subject: [PATCH 2/6] Draft --- examples/animation.ipynb | 190 +++++++++++++++++++-------------------- ipycanvas/canvas.py | 46 +++++++++- 2 files changed, 136 insertions(+), 100 deletions(-) diff --git a/examples/animation.ipynb b/examples/animation.ipynb index f480ddb..5913a9b 100644 --- a/examples/animation.ipynb +++ b/examples/animation.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": null, + "execution_count": 65, "metadata": {}, "outputs": [], "source": [ @@ -11,7 +11,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 66, "metadata": {}, "outputs": [], "source": [ @@ -20,7 +20,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 67, "metadata": {}, "outputs": [], "source": [ @@ -29,36 +29,7 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def draw(canvas, t):\n", - " size = 1000\n", - " step = 20\n", - " t1 = t / 1000.0\n", - "\n", - " x = 0\n", - " while x < size + step:\n", - " y = 0\n", - " while y < size + step:\n", - " x_angle = y_angle = 2 * pi\n", - "\n", - " angle = x_angle * (x / size) + y_angle * (y / size)\n", - "\n", - " particle_x = x + 20 * cos(2 * pi * t1 + angle)\n", - " particle_y = y + 20 * sin(2 * pi * t1 + angle)\n", - "\n", - " canvas.fill_circle(particle_x, particle_y, 6)\n", - "\n", - " y = y + step\n", - "\n", - " x = x + step" - ] - }, - { - "cell_type": "code", - "execution_count": null, + "execution_count": 68, "metadata": {}, "outputs": [], "source": [ @@ -84,9 +55,25 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 69, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "abab8a4fb5724ad48ef91bae1d1412f8", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Canvas(height=1000, width=1000)" + ] + }, + "execution_count": 69, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "size = 1000\n", "canvas = Canvas(width=size, height=size)\n", @@ -94,129 +81,138 @@ "canvas" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 1: Using `from time import sleep` and `fill_circle`\n", - "\n", - "**Worst approach: Slow locally, slow using a remote server (MyBinder)**" - ] - }, { "cell_type": "code", - "execution_count": null, + "execution_count": 73, "metadata": {}, "outputs": [], "source": [ "from time import sleep\n", "\n", - "for i in range(200):\n", + "frames = []\n", + "\n", + "def on_new_frame(frame):\n", + " frames.append(frame)\n", + "\n", + "canvas.on_image_data(on_new_frame)\n", + "\n", + "for i in range(50):\n", " with hold_canvas():\n", " canvas.clear()\n", "\n", - " draw(canvas, i * 20.0)\n", + " fast_draw(canvas, i * 20.0)\n", + "\n", + " canvas.request_image_data()\n", "\n", " sleep(20 / 1000.0)" ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": 59, "metadata": {}, + "outputs": [], "source": [ - "### 2: Using `canvas.sleep` and `fill_circle`\n", - "\n", - "This caches the entire animation before sending it to the front-end. This results in a slow execution (caching), but it ensure a smooth animation on the front-end whichever the context (local or remote server).\n", - "\n", - "**Slow to execute, smooth animation**" + "from PIL import Image" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 60, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "504787" + ] + }, + "execution_count": 60, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "len(frames[0])" + ] + }, + { + "cell_type": "code", + "execution_count": 61, "metadata": {}, "outputs": [], "source": [ - "with hold_canvas():\n", - " for i in range(200):\n", - " canvas.clear()\n", - "\n", - " draw(canvas, i * 20.0)\n", - "\n", - " canvas.sleep(20)" + "with open('test.png', 'wb') as fobj:\n", + " fobj.write(frames[0])" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 62, "metadata": {}, "outputs": [], "source": [ - "canvas" + "img = Image.open('test.png')" ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": 63, "metadata": {}, + "outputs": [], "source": [ - "### 3: Using `from time import sleep` and the vectorized `fill_circles`\n", - "\n", - "**Super fast locally, can be fast on a remote server if the latency is correct**" + "from PIL import Image\n", + "from io import BytesIO\n", + "import base64\n", + "import re" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 40, "metadata": {}, "outputs": [], "source": [ - "from time import sleep\n", - "\n", - "for i in range(200):\n", - " with hold_canvas():\n", - " canvas.clear()\n", - "\n", - " fast_draw(canvas, i * 20.0)\n", - "\n", - " sleep(20 / 1000.0)" + "# Image.open(BytesIO(frames[0]))" ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": 32, "metadata": {}, + "outputs": [], "source": [ - "### 4: Using `canvas.sleep` and the vectorized `fill_circles`\n", - "\n", - "**Best approach: Super fast locally, super fast on a remote server**" + "# Image.frombytes(data=frames[0].tobytes(), mode='RGBA', size=(canvas.width, canvas.height))" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 31, "metadata": {}, "outputs": [], "source": [ - "with hold_canvas():\n", - " for i in range(200):\n", - " canvas.clear()\n", - "\n", - " fast_draw(canvas, i * 20.0)\n", - "\n", - " canvas.sleep(20)" + "# frames[0].tobytes()" ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": 64, "metadata": {}, + "outputs": [], "source": [ - "### Conclusion\n", + "from PIL import Image\n", "\n", - "Always use `hold_canvas`!\n", + "imgs = [Image.open(BytesIO(frame)) for frame in frames]\n", "\n", - "As much as possible, try to use the vectorized version of the base methods if you want to exectute them multiple times (`fill_circles`, `fill_rects` etc).\n", - "\n", - "If you can, make use of `canvas.sleep` instead of `from time import sleep` so that the entire animation is sent at once to the front-end, making a smoother animation whatever the server latency." + "imgs[0].save('temp_result.gif', save_all=True, optimize=True, append_images=imgs[1:], loop=0, disposal=2, duration=30)" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { @@ -235,7 +231,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.5" } }, "nbformat": 4, diff --git a/ipycanvas/canvas.py b/ipycanvas/canvas.py index 499e9da..c371b18 100644 --- a/ipycanvas/canvas.py +++ b/ipycanvas/canvas.py @@ -262,6 +262,48 @@ def _send_custom(self, command, buffers=[]): _CANVAS_MANAGER = _CanvasManager() +def save_gif( + filename, + frames, + background=None, + transparency=None, + version="GIF87a", + frequency=1 / 30.0, + loop=0, +): + """Save a GIF file from frames. + + This uses Pillow under the hood. + + Args: + filename (str): The name of the file to save. *e.g.* "test.gif" + frames (list): The list of frames captured by ipycanvas + + background (): Default background color + transparency (): Transparency color index. This key is omitted if the image is not transparent + version (str): The GIF version, either "GIF87a" or "GIF89a" + frequency (float): The time between frames of the GIF, in milliseconds + loop (int): The number of times the GIF should loop. 0 means that it will loop forever + + """ + from PIL import Image + + imgs = [Image.open(BytesIO(frame)) for frame in frames] + + imgs[0].save( + filename, + save_all=True, + optimize=True, + append_images=imgs[1:], + loop=loop, + background=background, + transparency=transparency, + version=version, + duration=frequency, + disposal=2, + ) + + class Path2D(Widget): """Create a Path2D. @@ -637,9 +679,7 @@ def sleep(self, time): self._canvas_manager.send_draw_command(self, COMMANDS["sleep"], [time]) def request_image_data(self): - self._canvas_manager.send_draw_command( - self, COMMANDS["requestImageData"] - ) + self._canvas_manager.send_draw_command(self, COMMANDS["requestImageData"]) # Gradient methods def create_linear_gradient(self, x0, y0, x1, y1, color_stops): From 794ce008e5fd90a0f34af6ad60633ca6204acb07 Mon Sep 17 00:00:00 2001 From: martinRenou Date: Fri, 21 Oct 2022 18:59:35 +0200 Subject: [PATCH 3/6] Improve save_gif API --- ipycanvas/__init__.py | 1 + ipycanvas/canvas.py | 19 ++++++------------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/ipycanvas/__init__.py b/ipycanvas/__init__.py index 3883815..d02762c 100644 --- a/ipycanvas/__init__.py +++ b/ipycanvas/__init__.py @@ -11,6 +11,7 @@ MultiCanvas, MultiRoughCanvas, hold_canvas, + save_gif, ) # noqa from ._version import __version__ # noqa diff --git a/ipycanvas/canvas.py b/ipycanvas/canvas.py index c371b18..55dc43e 100644 --- a/ipycanvas/canvas.py +++ b/ipycanvas/canvas.py @@ -5,6 +5,7 @@ # Distributed under the terms of the Modified BSD License. import warnings +from io import BytesIO from contextlib import contextmanager import numpy as np @@ -265,11 +266,9 @@ def _send_custom(self, command, buffers=[]): def save_gif( filename, frames, - background=None, - transparency=None, - version="GIF87a", - frequency=1 / 30.0, + frequency=20, loop=0, + version="GIF87a", ): """Save a GIF file from frames. @@ -278,13 +277,9 @@ def save_gif( Args: filename (str): The name of the file to save. *e.g.* "test.gif" frames (list): The list of frames captured by ipycanvas - - background (): Default background color - transparency (): Transparency color index. This key is omitted if the image is not transparent + frequency (int): The time between frames of the GIF, in milliseconds. Defaults to 20 (50Hz). + loop (int): The number of times the GIF should loop. 0 means that it will loop forever. Defaults to 0. version (str): The GIF version, either "GIF87a" or "GIF89a" - frequency (float): The time between frames of the GIF, in milliseconds - loop (int): The number of times the GIF should loop. 0 means that it will loop forever - """ from PIL import Image @@ -292,12 +287,10 @@ def save_gif( imgs[0].save( filename, + append_images=imgs[1:], save_all=True, optimize=True, - append_images=imgs[1:], loop=loop, - background=background, - transparency=transparency, version=version, duration=frequency, disposal=2, From 3627786312548a7aa7eff3c396e012ffdbe978fc Mon Sep 17 00:00:00 2001 From: martinRenou Date: Fri, 21 Oct 2022 19:35:54 +0200 Subject: [PATCH 4/6] save_gif function as a context manager --- ipycanvas/canvas.py | 107 +++++++++++++++++++++++++++++--------------- 1 file changed, 71 insertions(+), 36 deletions(-) diff --git a/ipycanvas/canvas.py b/ipycanvas/canvas.py index 55dc43e..4de8046 100644 --- a/ipycanvas/canvas.py +++ b/ipycanvas/canvas.py @@ -8,6 +8,8 @@ from io import BytesIO from contextlib import contextmanager +from PIL import Image as PILImage + import numpy as np from traitlets import ( @@ -263,40 +265,6 @@ def _send_custom(self, command, buffers=[]): _CANVAS_MANAGER = _CanvasManager() -def save_gif( - filename, - frames, - frequency=20, - loop=0, - version="GIF87a", -): - """Save a GIF file from frames. - - This uses Pillow under the hood. - - Args: - filename (str): The name of the file to save. *e.g.* "test.gif" - frames (list): The list of frames captured by ipycanvas - frequency (int): The time between frames of the GIF, in milliseconds. Defaults to 20 (50Hz). - loop (int): The number of times the GIF should loop. 0 means that it will loop forever. Defaults to 0. - version (str): The GIF version, either "GIF87a" or "GIF89a" - """ - from PIL import Image - - imgs = [Image.open(BytesIO(frame)) for frame in frames] - - imgs[0].save( - filename, - append_images=imgs[1:], - save_all=True, - optimize=True, - loop=loop, - version=version, - duration=frequency, - disposal=2, - ) - - class Path2D(Widget): """Create a Path2D. @@ -451,6 +419,11 @@ class _CanvasBase(DOMWidget): sync=True, **bytes_serialization ) + def __init__(self, *args, **kwargs): + self._requested_frames = 0 + + super(_CanvasBase, self).__init__(*args, **kwargs) + def to_file(self, filename): """Save the current Canvas image to a PNG file. @@ -671,7 +644,12 @@ def sleep(self, time): """Make the Canvas sleep for `time` milliseconds.""" self._canvas_manager.send_draw_command(self, COMMANDS["sleep"], [time]) - def request_image_data(self): + def frame(self): + """Request a frame to the Canvas. + + TODO + """ + self._requested_frames += 1 self._canvas_manager.send_draw_command(self, COMMANDS["requestImageData"]) # Gradient methods @@ -1532,7 +1510,10 @@ def on_client_ready(self, callback, remove=False): """ self._client_ready_callbacks.register_callback(callback, remove=remove) - def on_image_data(self, callback, remove=False): + def on_new_frame(self, callback, remove=False): + """ + TODO + """ self._image_data_callbacks.register_callback(callback, remove=remove) def on_mouse_move(self, callback, remove=False): @@ -1863,3 +1844,57 @@ def hold_canvas(canvas=None): if not orig_caching: _CANVAS_MANAGER._caching = False + +@contextmanager +def save_gif( + canvas, + filename, + frequency=20, + loop=0, + version="GIF87a", +): + """Save a GIF file. + + TODO example + TODO say that the file is probably not finished creating when this function returns + + This uses Pillow under the hood. + + Args: + canvas (Canvas or MultiCanvas): The Canvas from which to generate the GIF. + filename (str): The name of the file to save. *e.g.* "test.gif" + frames (list): The list of frames captured by ipycanvas + frequency (int): The time between frames of the GIF, in milliseconds. Defaults to 20 (50Hz). + loop (int): The number of times the GIF should loop. 0 means that it will loop forever. Defaults to 0. + version (str): The GIF version, either "GIF87a" or "GIF89a" + """ + if canvas._requested_frames != 0: + raise RuntimeError("Impossible to save a GIF for this Canvas as a GIF is already being processed.") + + frames = [] + + def on_new_frame(frame): + frames.append(frame) + + if len(frames) == canvas._requested_frames: + imgs = [PILImage.open(BytesIO(frame)) for frame in frames] + + imgs[0].save( + filename, + append_images=imgs[1:], + save_all=True, + optimize=True, + loop=loop, + version=version, + duration=frequency, + disposal=2, + ) + + # Cleanup + canvas._requested_frames = 0 + canvas.on_new_frame(on_new_frame, remove=True) + + canvas.on_new_frame(on_new_frame) + + with hold_canvas(): + yield From 4b1d4de1e01398d20a3dca0d86fdc0d9d1db6036 Mon Sep 17 00:00:00 2001 From: martinRenou Date: Mon, 24 Oct 2022 09:11:04 +0200 Subject: [PATCH 5/6] Add comment --- examples/animation.ipynb | 162 ++++----------------------------------- ipycanvas/canvas.py | 1 + 2 files changed, 17 insertions(+), 146 deletions(-) diff --git a/examples/animation.ipynb b/examples/animation.ipynb index 5913a9b..33a701f 100644 --- a/examples/animation.ipynb +++ b/examples/animation.ipynb @@ -2,16 +2,16 @@ "cells": [ { "cell_type": "code", - "execution_count": 65, + "execution_count": 1, "metadata": {}, "outputs": [], "source": [ - "from ipycanvas import Canvas, hold_canvas" + "from ipycanvas import Canvas, hold_canvas, save_gif" ] }, { "cell_type": "code", - "execution_count": 66, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -20,7 +20,7 @@ }, { "cell_type": "code", - "execution_count": 67, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ @@ -29,7 +29,7 @@ }, { "cell_type": "code", - "execution_count": 68, + "execution_count": 4, "metadata": {}, "outputs": [], "source": [ @@ -55,164 +55,34 @@ }, { "cell_type": "code", - "execution_count": 69, + "execution_count": 5, "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "abab8a4fb5724ad48ef91bae1d1412f8", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "Canvas(height=1000, width=1000)" - ] - }, - "execution_count": 69, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "size = 1000\n", "canvas = Canvas(width=size, height=size)\n", - "canvas.fill_style = \"#fcba03\"\n", - "canvas" + "canvas.fill_style = \"#fcba03\"" ] }, { "cell_type": "code", - "execution_count": 73, + "execution_count": 6, "metadata": {}, "outputs": [], "source": [ "from time import sleep\n", "\n", - "frames = []\n", - "\n", - "def on_new_frame(frame):\n", - " frames.append(frame)\n", - "\n", - "canvas.on_image_data(on_new_frame)\n", + "# 50Hz\n", + "frequency = 20\n", "\n", - "for i in range(50):\n", - " with hold_canvas():\n", + "with save_gif(canvas, \"test.gif\", frequency=frequency):\n", + " for i in range(50):\n", " canvas.clear()\n", "\n", - " fast_draw(canvas, i * 20.0)\n", + " fast_draw(canvas, i * frequency)\n", "\n", - " canvas.request_image_data()\n", - "\n", - " sleep(20 / 1000.0)" + " canvas.frame()" ] - }, - { - "cell_type": "code", - "execution_count": 59, - "metadata": {}, - "outputs": [], - "source": [ - "from PIL import Image" - ] - }, - { - "cell_type": "code", - "execution_count": 60, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "504787" - ] - }, - "execution_count": 60, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "len(frames[0])" - ] - }, - { - "cell_type": "code", - "execution_count": 61, - "metadata": {}, - "outputs": [], - "source": [ - "with open('test.png', 'wb') as fobj:\n", - " fobj.write(frames[0])" - ] - }, - { - "cell_type": "code", - "execution_count": 62, - "metadata": {}, - "outputs": [], - "source": [ - "img = Image.open('test.png')" - ] - }, - { - "cell_type": "code", - "execution_count": 63, - "metadata": {}, - "outputs": [], - "source": [ - "from PIL import Image\n", - "from io import BytesIO\n", - "import base64\n", - "import re" - ] - }, - { - "cell_type": "code", - "execution_count": 40, - "metadata": {}, - "outputs": [], - "source": [ - "# Image.open(BytesIO(frames[0]))" - ] - }, - { - "cell_type": "code", - "execution_count": 32, - "metadata": {}, - "outputs": [], - "source": [ - "# Image.frombytes(data=frames[0].tobytes(), mode='RGBA', size=(canvas.width, canvas.height))" - ] - }, - { - "cell_type": "code", - "execution_count": 31, - "metadata": {}, - "outputs": [], - "source": [ - "# frames[0].tobytes()" - ] - }, - { - "cell_type": "code", - "execution_count": 64, - "metadata": {}, - "outputs": [], - "source": [ - "from PIL import Image\n", - "\n", - "imgs = [Image.open(BytesIO(frame)) for frame in frames]\n", - "\n", - "imgs[0].save('temp_result.gif', save_all=True, optimize=True, append_images=imgs[1:], loop=0, disposal=2, duration=30)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { @@ -231,7 +101,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.5" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/ipycanvas/canvas.py b/ipycanvas/canvas.py index 4de8046..2f5ad57 100644 --- a/ipycanvas/canvas.py +++ b/ipycanvas/canvas.py @@ -1891,6 +1891,7 @@ def on_new_frame(frame): ) # Cleanup + # TODO Change this. This approach does not work if the user uses .frame() outside of save_gif canvas._requested_frames = 0 canvas.on_new_frame(on_new_frame, remove=True) From aadbc615af6adeb8161f79e5a2ad820e668c08b1 Mon Sep 17 00:00:00 2001 From: martinRenou Date: Sun, 13 Nov 2022 07:50:35 +0100 Subject: [PATCH 6/6] Send recorded frames in one batch --- ipycanvas/canvas.py | 16 ++++++++-------- src/widget.ts | 30 +++++++++++++++++++++--------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/ipycanvas/canvas.py b/ipycanvas/canvas.py index 2f5ad57..9b3d6f6 100644 --- a/ipycanvas/canvas.py +++ b/ipycanvas/canvas.py @@ -1510,7 +1510,7 @@ def on_client_ready(self, callback, remove=False): """ self._client_ready_callbacks.register_callback(callback, remove=remove) - def on_new_frame(self, callback, remove=False): + def on_new_frames(self, callback, remove=False): """ TODO """ @@ -1871,13 +1871,13 @@ def save_gif( if canvas._requested_frames != 0: raise RuntimeError("Impossible to save a GIF for this Canvas as a GIF is already being processed.") - frames = [] + received_frames = [] - def on_new_frame(frame): - frames.append(frame) + def on_new_frames(frames): + received_frames.extend(frames) - if len(frames) == canvas._requested_frames: - imgs = [PILImage.open(BytesIO(frame)) for frame in frames] + if len(received_frames) >= canvas._requested_frames: + imgs = [PILImage.open(BytesIO(frame)) for frame in received_frames] imgs[0].save( filename, @@ -1893,9 +1893,9 @@ def on_new_frame(frame): # Cleanup # TODO Change this. This approach does not work if the user uses .frame() outside of save_gif canvas._requested_frames = 0 - canvas.on_new_frame(on_new_frame, remove=True) + canvas.on_new_frame(on_new_frames, remove=True) - canvas.on_new_frame(on_new_frame) + canvas.on_new_frames(on_new_frames) with hold_canvas(): yield diff --git a/src/widget.ts b/src/widget.ts index 5072010..4a0cb8c 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -167,21 +167,31 @@ export class CanvasManagerModel extends WidgetModel { } } - private async processCommand(command: any, buffers: any) { + private async processCommand(command: any, buffers: any): Promise { // If it's a list of commands if (command instanceof Array && command[0] instanceof Array) { let remainingBuffers = buffers; + const frames: DataView[] = []; for (const subcommand of command) { let subbuffers = []; + const nBuffers: Number = subcommand[2]; if (nBuffers) { subbuffers = remainingBuffers.slice(0, nBuffers); remainingBuffers = remainingBuffers.slice(nBuffers); } - await this.processCommand(subcommand, subbuffers); + + const frame = await this.processCommand(subcommand, subbuffers); + + if (frame !== null) { + frames.push(frame); + } } - return; + + this.send({ event: 'image_data' }, {}, frames); + + return null; } const name: string = COMMANDS[command[0]]; @@ -192,8 +202,10 @@ export class CanvasManagerModel extends WidgetModel { this.canvasesToUpdate.push(this.currentCanvas); break; case 'requestImageData': - await this.currentCanvas.sendImageData(); - break; + for (const canvas of this.canvasesToUpdate) { + canvas.syncViews(); + } + return await this.currentCanvas.frame(); case 'sleep': await this.currentCanvas.sleep(args[0]); break; @@ -363,6 +375,8 @@ export class CanvasManagerModel extends WidgetModel { this.currentCanvas.executeCommand(name, args); break; } + + return null; } private async switchCanvas(serializedCanvas: any) { @@ -638,11 +652,9 @@ export class CanvasModel extends DOMWidgetModel { await new Promise(resolve => setTimeout(resolve, time)); } - async sendImageData(): Promise { + async frame(): Promise { const bytes = await toBytes(this.canvas); - const imageData = serializeImageData(bytes); - - this.send({ event: 'image_data' }, {}, [imageData]); + return serializeImageData(bytes); } fillRect(x: number, y: number, width: number, height: number) {