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. 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 = """ + + +""" + + +# HTML template for HTMLWriter +DISPLAY_TEMPLATE = """ +
+ +
+ +
+ + + + + + + + + +
+ Once + Loop + Reflect +
+
+ + + +""" + +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..0009706a8d05 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,136 @@ 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', + 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' + warnings.warn("unrecognized default_mode: using 'loop'") + + self._saved_frames = [] + self._total_bytes = 0 + self._hit_limit = False + 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: + # 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) + 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} " + "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) + + 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 = 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. @@ -1241,7 +1378,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 @@ -1256,6 +1393,13 @@ def to_html5_video(self): ''' # 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', @@ -1271,28 +1415,75 @@ 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 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""" + 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) - # 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)) + 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..e17f93874704 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): @@ -1379,11 +1380,16 @@ 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], # 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/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') ] 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