diff --git a/examples/animation.ipynb b/examples/animation.ipynb index f480ddb..33a701f 100644 --- a/examples/animation.ipynb +++ b/examples/animation.ipynb @@ -2,16 +2,16 @@ "cells": [ { "cell_type": "code", - "execution_count": null, + "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": null, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -20,7 +20,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "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": 4, "metadata": {}, "outputs": [], "source": [ @@ -84,138 +55,33 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "metadata": {}, "outputs": [], "source": [ "size = 1000\n", "canvas = Canvas(width=size, height=size)\n", - "canvas.fill_style = \"#fcba03\"\n", - "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)**" + "canvas.fill_style = \"#fcba03\"" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "metadata": {}, "outputs": [], "source": [ "from time import sleep\n", "\n", - "for i in range(200):\n", - " with hold_canvas():\n", - " canvas.clear()\n", - "\n", - " draw(canvas, i * 20.0)\n", + "# 50Hz\n", + "frequency = 20\n", "\n", - " sleep(20 / 1000.0)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "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**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "with hold_canvas():\n", - " for i in range(200):\n", + "with save_gif(canvas, \"test.gif\", frequency=frequency):\n", + " for i in range(50):\n", " canvas.clear()\n", "\n", - " draw(canvas, i * 20.0)\n", - "\n", - " canvas.sleep(20)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "canvas" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "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**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "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)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 4: Using `canvas.sleep` and the vectorized `fill_circles`\n", - "\n", - "**Best approach: Super fast locally, super fast on a remote server**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "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)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Conclusion\n", - "\n", - "Always use `hold_canvas`!\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", + " fast_draw(canvas, i * frequency)\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." + " canvas.frame()" ] } ], @@ -235,7 +101,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, 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 3986458..9b3d6f6 100644 --- a/ipycanvas/canvas.py +++ b/ipycanvas/canvas.py @@ -5,8 +5,11 @@ # Distributed under the terms of the Modified BSD License. import warnings +from io import BytesIO from contextlib import contextmanager +from PIL import Image as PILImage + import numpy as np from traitlets import ( @@ -103,6 +106,7 @@ "strokeStyledPolygons", "strokeStyledLineSegments", "switchCanvas", + "requestImageData", ] COMMANDS = {v: i for i, v in enumerate(_CMD_LIST)} @@ -415,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. @@ -582,6 +591,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 +644,14 @@ def sleep(self, time): """Make the Canvas sleep for `time` milliseconds.""" self._canvas_manager.send_draw_command(self, COMMANDS["sleep"], [time]) + 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 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 +1510,12 @@ def on_client_ready(self, callback, remove=False): """ self._client_ready_callbacks.register_callback(callback, remove=remove) + def on_new_frames(self, callback, remove=False): + """ + TODO + """ + 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 +1567,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"]) @@ -1817,3 +1844,58 @@ 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.") + + received_frames = [] + + def on_new_frames(frames): + received_frames.extend(frames) + + if len(received_frames) >= canvas._requested_frames: + imgs = [PILImage.open(BytesIO(frame)) for frame in received_frames] + + imgs[0].save( + filename, + append_images=imgs[1:], + save_all=True, + optimize=True, + loop=loop, + version=version, + duration=frequency, + disposal=2, + ) + + # 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_frames, remove=True) + + canvas.on_new_frames(on_new_frames) + + with hold_canvas(): + yield diff --git a/src/widget.ts b/src/widget.ts index c706a3f..4a0cb8c 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 { @@ -166,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]]; @@ -190,6 +201,11 @@ export class CanvasManagerModel extends WidgetModel { await this.switchCanvas(args[0]); this.canvasesToUpdate.push(this.currentCanvas); break; + case 'requestImageData': + for (const canvas of this.canvasesToUpdate) { + canvas.syncViews(); + } + return await this.currentCanvas.frame(); case 'sleep': await this.currentCanvas.sleep(args[0]); break; @@ -359,6 +375,8 @@ export class CanvasManagerModel extends WidgetModel { this.currentCanvas.executeCommand(name, args); break; } + + return null; } private async switchCanvas(serializedCanvas: any) { @@ -634,6 +652,11 @@ export class CanvasModel extends DOMWidgetModel { await new Promise(resolve => setTimeout(resolve, time)); } + async frame(): Promise { + const bytes = await toBytes(this.canvas); + return serializeImageData(bytes); + } + fillRect(x: number, y: number, width: number, height: number) { this.ctx.fillRect(x, y, width, height); } @@ -1493,9 +1516,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 } };