From 34b6ea32e842a14896fcab610b1021f041208532 Mon Sep 17 00:00:00 2001 From: Ryan May <rmay31@gmail.com> Date: Mon, 27 Jul 2015 23:20:37 -0500 Subject: [PATCH 1/5] Import JSAnimation into the animation module. This pulls http://github.com/jakevdp/JSAnimation into the code. Most of this is in the HTMLWriter class. This also adds the `jshtml` option for the animation.html setting. --- lib/matplotlib/_animation_data.py | 210 ++++++++++++++++++++++++++++++ lib/matplotlib/animation.py | 146 +++++++++++++++++++++ lib/matplotlib/rcsetup.py | 7 +- matplotlibrc.template | 1 + 4 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 lib/matplotlib/_animation_data.py diff --git a/lib/matplotlib/_animation_data.py b/lib/matplotlib/_animation_data.py new file mode 100644 index 000000000000..4c3f2c75b65e --- /dev/null +++ b/lib/matplotlib/_animation_data.py @@ -0,0 +1,210 @@ +# Javascript template for HTMLWriter +JS_INCLUDE = """ +<link rel="stylesheet" +href="https://maxcdn.bootstrapcdn.com/font-awesome/4.4.0/ +css/font-awesome.min.css"> +<script language="javascript"> + /* Define the Animation class */ + function Animation(frames, img_id, slider_id, interval, loop_select_id){ + this.img_id = img_id; + this.slider_id = slider_id; + this.loop_select_id = loop_select_id; + this.interval = interval; + this.current_frame = 0; + this.direction = 0; + this.timer = null; + this.frames = new Array(frames.length); + + for (var i=0; i<frames.length; i++) + { + this.frames[i] = new Image(); + this.frames[i].src = frames[i]; + } + document.getElementById(this.slider_id).max = this.frames.length - 1; + this.set_frame(this.current_frame); + } + + Animation.prototype.get_loop_state = function(){ + var button_group = document[this.loop_select_id].state; + for (var i = 0; i < button_group.length; i++) { + var button = button_group[i]; + if (button.checked) { + return button.value; + } + } + return undefined; + } + + Animation.prototype.set_frame = function(frame){ + this.current_frame = frame; + document.getElementById(this.img_id).src = + this.frames[this.current_frame].src; + document.getElementById(this.slider_id).value = this.current_frame; + } + + Animation.prototype.next_frame = function() + { + this.set_frame(Math.min(this.frames.length - 1, this.current_frame + 1)); + } + + Animation.prototype.previous_frame = function() + { + this.set_frame(Math.max(0, this.current_frame - 1)); + } + + Animation.prototype.first_frame = function() + { + this.set_frame(0); + } + + Animation.prototype.last_frame = function() + { + this.set_frame(this.frames.length - 1); + } + + Animation.prototype.slower = function() + { + this.interval /= 0.7; + if(this.direction > 0){this.play_animation();} + else if(this.direction < 0){this.reverse_animation();} + } + + Animation.prototype.faster = function() + { + this.interval *= 0.7; + if(this.direction > 0){this.play_animation();} + else if(this.direction < 0){this.reverse_animation();} + } + + Animation.prototype.anim_step_forward = function() + { + this.current_frame += 1; + if(this.current_frame < this.frames.length){ + this.set_frame(this.current_frame); + }else{ + var loop_state = this.get_loop_state(); + if(loop_state == "loop"){ + this.first_frame(); + }else if(loop_state == "reflect"){ + this.last_frame(); + this.reverse_animation(); + }else{ + this.pause_animation(); + this.last_frame(); + } + } + } + + Animation.prototype.anim_step_reverse = function() + { + this.current_frame -= 1; + if(this.current_frame >= 0){ + this.set_frame(this.current_frame); + }else{ + var loop_state = this.get_loop_state(); + if(loop_state == "loop"){ + this.last_frame(); + }else if(loop_state == "reflect"){ + this.first_frame(); + this.play_animation(); + }else{ + this.pause_animation(); + this.first_frame(); + } + } + } + + Animation.prototype.pause_animation = function() + { + this.direction = 0; + if (this.timer){ + clearInterval(this.timer); + this.timer = null; + } + } + + Animation.prototype.play_animation = function() + { + this.pause_animation(); + this.direction = 1; + var t = this; + if (!this.timer) this.timer = setInterval(function() { + t.anim_step_forward(); + }, this.interval); + } + + Animation.prototype.reverse_animation = function() + { + this.pause_animation(); + this.direction = -1; + var t = this; + if (!this.timer) this.timer = setInterval(function() { + t.anim_step_reverse(); + }, this.interval); + } +</script> +""" + + +# HTML template for HTMLWriter +DISPLAY_TEMPLATE = """ +<div class="animation" align="center"> + <img id="_anim_img{id}"> + <br> + <input id="_anim_slider{id}" type="range" style="width:350px" + name="points" min="0" max="1" step="1" value="0" + onchange="anim{id}.set_frame(parseInt(this.value));"></input> + <br> + <button onclick="anim{id}.slower()"><i class="fa fa-minus"></i></button> + <button onclick="anim{id}.first_frame()"><i class="fa fa-fast-backward"> + </i></button> + <button onclick="anim{id}.previous_frame()"> + <i class="fa fa-step-backward"></i></button> + <button onclick="anim{id}.reverse_animation()"> + <i class="fa fa-play fa-flip-horizontal"></i></button> + <button onclick="anim{id}.pause_animation()"><i class="fa fa-pause"> + </i></button> + <button onclick="anim{id}.play_animation()"><i class="fa fa-play"></i> + </button> + <button onclick="anim{id}.next_frame()"><i class="fa fa-step-forward"> + </i></button> + <button onclick="anim{id}.last_frame()"><i class="fa fa-fast-forward"> + </i></button> + <button onclick="anim{id}.faster()"><i class="fa fa-plus"></i></button> + <form action="#n" name="_anim_loop_select{id}" class="anim_control"> + <input type="radio" name="state" + value="once" {once_checked}> Once </input> + <input type="radio" name="state" + value="loop" {loop_checked}> Loop </input> + <input type="radio" name="state" + value="reflect" {reflect_checked}> Reflect </input> + </form> +</div> + + +<script language="javascript"> + /* Instantiate the Animation class. */ + /* The IDs given should match those used in the template above. */ + (function() {{ + var img_id = "_anim_img{id}"; + var slider_id = "_anim_slider{id}"; + var loop_select_id = "_anim_loop_select{id}"; + var frames = new Array({Nframes}); + {fill_frames} + + /* set a timeout to make sure all the above elements are created before + the object is initialized. */ + setTimeout(function() {{ + anim{id} = new Animation(frames, img_id, slider_id, {interval}, + loop_select_id); + }}, 0); + }})() +</script> +""" + +INCLUDED_FRAMES = """ + for (var i=0; i<{Nframes}; i++){{ + frames[i] = "{frame_dir}/frame" + ("0000000" + i).slice(-7) + + ".{frame_format}"; + }} +""" diff --git a/lib/matplotlib/animation.py b/lib/matplotlib/animation.py index 6c80408a7219..6cf753f62b43 100644 --- a/lib/matplotlib/animation.py +++ b/lib/matplotlib/animation.py @@ -37,11 +37,18 @@ import abc import contextlib import tempfile +import uuid import warnings +from matplotlib._animation_data import (DISPLAY_TEMPLATE, INCLUDED_FRAMES, + JS_INCLUDE) from matplotlib.cbook import iterable, deprecated from matplotlib.compat import subprocess from matplotlib import verbose from matplotlib import rcParams, rcParamsDefault, rc_context +if sys.version_info < (3, 0): + from cStringIO import StringIO as InMemory +else: + from io import BytesIO as InMemory # Process creation flag for subprocess to prevent it raising a terminal # window. See for example: @@ -876,6 +883,112 @@ def _args(self): + self.output_args) +# Taken directly from jakevdp's JSAnimation package at +# http://github.com/jakevdp/JSAnimation +def _included_frames(frame_list, frame_format): + """frame_list should be a list of filenames""" + return INCLUDED_FRAMES.format(Nframes=len(frame_list), + frame_dir=os.path.dirname(frame_list[0]), + frame_format=frame_format) + + +def _embedded_frames(frame_list, frame_format): + """frame_list should be a list of base64-encoded png files""" + template = ' frames[{0}] = "data:image/{1};base64,{2}"\n' + embedded = "\n" + for i, frame_data in enumerate(frame_list): + embedded += template.format(i, frame_format, + frame_data.replace('\n', '\\\n')) + return embedded + + +@writers.register('html') +class HTMLWriter(FileMovieWriter): + supported_formats = ['png', 'jpeg', 'tiff', 'svg'] + args_key = 'animation.html_args' + + @classmethod + def isAvailable(cls): + return True + + def __init__(self, fps=30, codec=None, bitrate=None, extra_args=None, + metadata=None, embed_frames=False, default_mode='loop'): + self.embed_frames = embed_frames + self.default_mode = default_mode.lower() + + if self.default_mode not in ['loop', 'once', 'reflect']: + self.default_mode = 'loop' + import warnings + warnings.warn("unrecognized default_mode: using 'loop'") + + self._saved_frames = list() + super(HTMLWriter, self).__init__(fps, codec, bitrate, + extra_args, metadata) + + def setup(self, fig, outfile, dpi, frame_dir=None): + if os.path.splitext(outfile)[-1] not in ['.html', '.htm']: + raise ValueError("outfile must be *.htm or *.html") + + if not self.embed_frames: + if frame_dir is None: + frame_dir = outfile.rstrip('.html') + '_frames' + if not os.path.exists(frame_dir): + os.makedirs(frame_dir) + frame_prefix = os.path.join(frame_dir, 'frame') + else: + frame_prefix = None + + super(HTMLWriter, self).setup(fig, outfile, dpi, + frame_prefix, clear_temp=False) + + def grab_frame(self, **savefig_kwargs): + if self.embed_frames: + suffix = '.' + self.frame_format + f = InMemory() + self.fig.savefig(f, format=self.frame_format, + dpi=self.dpi, **savefig_kwargs) + f.seek(0) + imgdata64 = encodebytes(f.read()).decode('ascii') + self._saved_frames.append(imgdata64) + else: + return super(HTMLWriter, self).grab_frame(**savefig_kwargs) + + def _run(self): + # make a duck-typed subprocess stand in + # this is called by the MovieWriter base class, but not used here. + class ProcessStandin(object): + returncode = 0 + + def communicate(self): + return '', '' + + self._proc = ProcessStandin() + + # save the frames to an html file + if self.embed_frames: + fill_frames = _embedded_frames(self._saved_frames, + self.frame_format) + else: + # temp names is filled by FileMovieWriter + fill_frames = _included_frames(self._temp_names, + self.frame_format) + + mode_dict = dict(once_checked='', + loop_checked='', + reflect_checked='') + mode_dict[self.default_mode + '_checked'] = 'checked' + + interval = int(1000. / self.fps) + + with open(self.outfile, 'w') as of: + of.write(JS_INCLUDE) + of.write(DISPLAY_TEMPLATE.format(id=uuid.uuid4().hex, + Nframes=len(self._temp_names), + fill_frames=fill_frames, + interval=interval, + **mode_dict)) + + class Animation(object): '''This class wraps the creation of an animation using matplotlib. @@ -1288,11 +1401,44 @@ def to_html5_video(self): size=self._video_size, options=' '.join(options)) + def to_jshtml(self, fps=None, embed_frames=True, default_mode=None): + """Generate HTML representation of the animation""" + if fps is None and hasattr(self, '_interval'): + # Convert interval in ms to frames per second + fps = 1000. / self._interval + + # If we're not given a default mode, choose one base on the value of + # the repeat attribute + if default_mode is None: + default_mode = 'loop' if self.repeat else 'once' + + if hasattr(self, "_html_representation"): + return self._html_representation + else: + # Can't open a second time while opened on windows. So we avoid + # deleting when closed, and delete manually later. + with tempfile.NamedTemporaryFile(suffix='.html', + delete=False) as f: + self.save(f.name, writer=HTMLWriter(fps=fps, + embed_frames=embed_frames, + default_mode=default_mode)) + # Re-open and get content + with open(f.name) as fobj: + html = fobj.read() + + # Now we can delete + os.remove(f.name) + + self._html_representation = html + return html + def _repr_html_(self): '''IPython display hook for rendering.''' fmt = rcParams['animation.html'] if fmt == 'html5': return self.to_html5_video() + elif fmt == 'jshtml': + return self.to_jshtml() class TimedAnimation(Animation): diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 5bf8c5cee59e..b24557334b0b 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -602,7 +602,8 @@ def validate_hinting(s): ['ffmpeg', 'ffmpeg_file', 'avconv', 'avconv_file', 'mencoder', 'mencoder_file', - 'imagemagick', 'imagemagick_file']) + 'imagemagick', 'imagemagick_file', + 'html']) validate_movie_frame_fmt = ValidateInStrings('animation.frame_format', ['png', 'jpeg', 'tiff', 'raw', 'rgba']) @@ -610,7 +611,7 @@ def validate_hinting(s): validate_axis_locator = ValidateInStrings('major', ['minor', 'both', 'major']) validate_movie_html_fmt = ValidateInStrings('animation.html', - ['html5', 'none']) + ['html5', 'jshtml', 'none']) def validate_bbox(s): if isinstance(s, six.string_types): @@ -1384,6 +1385,8 @@ def _validate_linestyle(ls): 'animation.bitrate': [-1, validate_int], # Controls image format when frames are written to disk 'animation.frame_format': ['png', validate_movie_frame_fmt], + # Additional arguments for HTML writer + 'animation.html_args': [[], validate_stringlist], # Path to FFMPEG binary. If just binary name, subprocess uses $PATH. 'animation.ffmpeg_path': ['ffmpeg', validate_animation_writer_path], diff --git a/matplotlibrc.template b/matplotlibrc.template index 2a6e8b273fb5..991d200860e5 100644 --- a/matplotlibrc.template +++ b/matplotlibrc.template @@ -609,6 +609,7 @@ backend : $TEMPLATE_BACKEND #animation.bitrate: -1 # Controls size/quality tradeoff for movie. # -1 implies let utility auto-determine #animation.frame_format: 'png' # Controls frame format used by temp files +#animation.html_args: '' # Additional arguments to pass to html writer #animation.ffmpeg_path: 'ffmpeg' # Path to ffmpeg binary. Without full path # $PATH is searched #animation.ffmpeg_args: '' # Additional arguments to pass to ffmpeg From 11f04648aa431ba01c71d1a7b8e435aaf5341fca Mon Sep 17 00:00:00 2001 From: Ryan May <rmay31@gmail.com> Date: Tue, 4 Aug 2015 10:19:23 -0600 Subject: [PATCH 2/5] Limit size of HTML embedded animations. This makes both the HTMLWriter and the HTML5 video support check the amount of data being saved into HTML against the rc parameter "animation.embed_limit". --- lib/matplotlib/animation.py | 75 ++++++++++++++++++++++++++++++------- lib/matplotlib/rcsetup.py | 3 ++ 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/lib/matplotlib/animation.py b/lib/matplotlib/animation.py index 6cf753f62b43..e304eaed1c10 100644 --- a/lib/matplotlib/animation.py +++ b/lib/matplotlib/animation.py @@ -912,16 +912,28 @@ def isAvailable(cls): return True def __init__(self, fps=30, codec=None, bitrate=None, extra_args=None, - metadata=None, embed_frames=False, default_mode='loop'): + metadata=None, embed_frames=False, default_mode='loop', + embed_limit=None): self.embed_frames = embed_frames self.default_mode = default_mode.lower() + # Save embed limit, which is given in MB + if embed_limit is None: + self._bytes_limit = rcParams['animation.embed_limit'] + else: + self._bytes_limit = embed_limit + + # Convert from MB to bytes + self._bytes_limit *= 1024 * 1024 + if self.default_mode not in ['loop', 'once', 'reflect']: self.default_mode = 'loop' import warnings warnings.warn("unrecognized default_mode: using 'loop'") self._saved_frames = list() + self._total_bytes = 0 + self._hit_limit = False super(HTMLWriter, self).__init__(fps, codec, bitrate, extra_args, metadata) @@ -943,13 +955,27 @@ def setup(self, fig, outfile, dpi, frame_dir=None): def grab_frame(self, **savefig_kwargs): if self.embed_frames: + # Just stop processing if we hit the limit + if self._hit_limit: + return suffix = '.' + self.frame_format f = InMemory() self.fig.savefig(f, format=self.frame_format, dpi=self.dpi, **savefig_kwargs) f.seek(0) imgdata64 = encodebytes(f.read()).decode('ascii') - self._saved_frames.append(imgdata64) + self._total_bytes += len(imgdata64) + if self._total_bytes >= self._bytes_limit: + warnings.warn("Animation size has reached {0._total_bytes} " + "bytes, exceeding the limit of " + "{0._bytes_limit}. If you're sure you want " + "a larger animation embedded, set the " + "animation.embed_limit rc parameter to a " + "larger value (in MB). This and further frames" + " will be dropped.".format(self)) + self._hit_limit = True + else: + self._saved_frames.append(imgdata64) else: return super(HTMLWriter, self).grab_frame(**savefig_kwargs) @@ -1354,7 +1380,7 @@ def _end_redraw(self, evt): self._resize_id = self._fig.canvas.mpl_connect('resize_event', self._handle_resize) - def to_html5_video(self): + def to_html5_video(self, embed_limit=None): '''Returns animation as an HTML5 video tag. This saves the animation as an h264 video, encoded in base64 @@ -1369,6 +1395,13 @@ def to_html5_video(self): </video>''' # Cache the rendering of the video as HTML if not hasattr(self, '_base64_video'): + # Save embed limit, which is given in MB + if embed_limit is None: + embed_limit = rcParams['animation.embed_limit'] + + # Convert from MB to bytes + embed_limit *= 1024 * 1024 + # First write the video to a tempfile. Set delete to False # so we can re-open to read binary data. with tempfile.NamedTemporaryFile(suffix='.m4v', @@ -1384,22 +1417,36 @@ def to_html5_video(self): # Now open and base64 encode with open(f.name, 'rb') as video: vid64 = encodebytes(video.read()) - self._base64_video = vid64.decode('ascii') - self._video_size = 'width="{0}" height="{1}"'.format( - *writer.frame_size) + vid_len = len(vid64) + if vid_len >= embed_limit: + warnings.warn("Animation movie is {} bytes, exceeding " + "the limit of {}. If you're sure you want a " + "large animation embedded, set the " + "animation.embed_limit rc parameter to a " + "larger value (in MB).".format(vid_len, + embed_limit)) + else: + self._base64_video = vid64.decode('ascii') + self._video_size = 'width="{}" height="{}"'.format( + *writer.frame_size) # Now we can remove os.remove(f.name) - # Default HTML5 options are to autoplay and to display video controls - options = ['controls', 'autoplay'] + # If we exceeded the size, this attribute won't exist + if hasattr(self, '_base64_video'): + # Default HTML5 options are to autoplay and display video controls + options = ['controls', 'autoplay'] - # If we're set to repeat, make it loop - if self.repeat: - options.append('loop') - return VIDEO_TAG.format(video=self._base64_video, - size=self._video_size, - options=' '.join(options)) + # If we're set to repeat, make it loop + if hasattr(self, 'repeat') and self.repeat: + options.append('loop') + + return VIDEO_TAG.format(video=self._base64_video, + size=self._video_size, + options=' '.join(options)) + else: + return 'Video too large to embed.' def to_jshtml(self, fps=None, embed_frames=True, default_mode=None): """Generate HTML representation of the animation""" diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index b24557334b0b..e17f93874704 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -1380,6 +1380,9 @@ def _validate_linestyle(ls): # Animation settings 'animation.html': ['none', validate_movie_html_fmt], + # Limit, in MB, of size of base64 encoded animation in HTML + # (i.e. IPython notebook) + 'animation.embed_limit': [20, validate_float], 'animation.writer': ['ffmpeg', validate_movie_writer], 'animation.codec': ['h264', six.text_type], 'animation.bitrate': [-1, validate_int], From c0c8fe3c008a37100f74dd06959da6f8842d7bb8 Mon Sep 17 00:00:00 2001 From: Ryan May <rmay31@gmail.com> Date: Thu, 3 Dec 2015 09:33:14 -0700 Subject: [PATCH 3/5] Add What's New file for JSAnimation. --- doc/users/next_whats_new/js-animation.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 doc/users/next_whats_new/js-animation.rst diff --git a/doc/users/next_whats_new/js-animation.rst b/doc/users/next_whats_new/js-animation.rst new file mode 100644 index 000000000000..948c06ffceb6 --- /dev/null +++ b/doc/users/next_whats_new/js-animation.rst @@ -0,0 +1,16 @@ +Merge JSAnimation +----------------- + +Jake Vanderplas' JSAnimation package has been merged into matplotlib. This +adds to matplotlib the `~matplotlib.animation.HTMLWriter` class for +generating a javascript HTML animation, suitable for the IPython notebook. +This can be activated by default by setting the ``animation.html`` rc +parameter to ``jshtml``. One can also call the ``anim_to_jshtml`` function +to manually convert an animation. This can be displayed using IPython's +``HTML`` display class:: + + from IPython.display import HTML + HTML(animation.anim_to_jshtml(anim)) + +The `~matplotlib.animation.HTMLWriter` class can also be used to generate +an HTML file by asking for the ``html`` writer. From ace67007ae0e8698cbeb0e637b2342e1ccfa1177 Mon Sep 17 00:00:00 2001 From: Ryan May <rmay@ucar.edu> Date: Mon, 8 May 2017 16:04:23 -0600 Subject: [PATCH 4/5] ENH: Add HTMLWriter to animation smoke tests --- lib/matplotlib/tests/test_animation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/matplotlib/tests/test_animation.py b/lib/matplotlib/tests/test_animation.py index 017727016fe0..602b57384bb1 100644 --- a/lib/matplotlib/tests/test_animation.py +++ b/lib/matplotlib/tests/test_animation.py @@ -121,6 +121,7 @@ def isAvailable(self): ('avconv_file', 'mp4'), ('imagemagick', 'gif'), ('imagemagick_file', 'gif'), + ('html', 'html'), ('null', 'null') ] From ffb2ccd5d69e1f68bdc383c42a05ee164b6f57c7 Mon Sep 17 00:00:00 2001 From: Ryan May <rmay31@gmail.com> Date: Thu, 31 Aug 2017 18:01:54 -0600 Subject: [PATCH 5/5] Some cleanups to HTMLWriter. --- lib/matplotlib/animation.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/animation.py b/lib/matplotlib/animation.py index e304eaed1c10..0009706a8d05 100644 --- a/lib/matplotlib/animation.py +++ b/lib/matplotlib/animation.py @@ -928,10 +928,9 @@ def __init__(self, fps=30, codec=None, bitrate=None, extra_args=None, if self.default_mode not in ['loop', 'once', 'reflect']: self.default_mode = 'loop' - import warnings warnings.warn("unrecognized default_mode: using 'loop'") - self._saved_frames = list() + self._saved_frames = [] self._total_bytes = 0 self._hit_limit = False super(HTMLWriter, self).__init__(fps, codec, bitrate, @@ -962,8 +961,7 @@ def grab_frame(self, **savefig_kwargs): f = InMemory() self.fig.savefig(f, format=self.frame_format, dpi=self.dpi, **savefig_kwargs) - f.seek(0) - imgdata64 = encodebytes(f.read()).decode('ascii') + imgdata64 = encodebytes(f.getvalue()).decode('ascii') self._total_bytes += len(imgdata64) if self._total_bytes >= self._bytes_limit: warnings.warn("Animation size has reached {0._total_bytes} " @@ -1004,7 +1002,7 @@ def communicate(self): reflect_checked='') mode_dict[self.default_mode + '_checked'] = 'checked' - interval = int(1000. / self.fps) + interval = 1000 // self.fps with open(self.outfile, 'w') as of: of.write(JS_INCLUDE) @@ -1452,7 +1450,7 @@ def to_jshtml(self, fps=None, embed_frames=True, default_mode=None): """Generate HTML representation of the animation""" if fps is None and hasattr(self, '_interval'): # Convert interval in ms to frames per second - fps = 1000. / self._interval + fps = 1000 / self._interval # If we're not given a default mode, choose one base on the value of # the repeat attribute