Programmatically generate SVG (vector) images, animations, and interactive Jupyter widgets

Add Drawing.as_gif, as_mp4, and as_video to rasterize animated SVGs

authored by cduck.me and committed by

Casey Duckering bd0eb18d e630d351

+172 -26
+53 -4
drawsvg/drawing.py
··· 5 5 import string 6 6 import xml.sax.saxutils as xml 7 7 8 - from . import Raster 9 - from . import types, elements as elements_module, jupyter 8 + from . import types, elements as elements_module, raster, video, jupyter 10 9 11 10 12 11 XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n' ··· 322 321 self.rasterize(to_file=fname, context=context) 323 322 def rasterize(self, to_file=None, context=None): 324 323 if to_file is not None: 325 - return Raster.from_svg_to_file( 324 + return raster.Raster.from_svg_to_file( 326 325 self.as_svg(context=context), to_file) 327 326 else: 328 - return Raster.from_svg(self.as_svg(context=context)) 327 + return raster.Raster.from_svg(self.as_svg(context=context)) 328 + def as_animation_frames(self, fps=10, duration=None, context=None): 329 + '''Returns a list of synced animation frames that can be converted to a 330 + video.''' 331 + if context is None: 332 + context = self.context 333 + if duration is None and context.animation_config is not None: 334 + duration = context.animation_config.duration 335 + if duration is None: 336 + raise ValueError('unknown animation duration, specify duration') 337 + frames = [] 338 + for i in range(int(duration * fps + 1)): 339 + time = i / fps 340 + frame_context = dataclasses.replace( 341 + context, 342 + animation_config=dataclasses.replace( 343 + context.animation_config, 344 + freeze_frame_at=time, 345 + show_playback_controls=False)) 346 + frames.append(self.display_inline(context=frame_context)) 347 + return frames 348 + def save_gif(self, fname, fps=10, duration=None, context=None): 349 + self.as_gif(fname, fps=fps, duration=duration, context=context) 350 + def save_mp4(self, fname, fps=10, duration=None, context=None): 351 + self.as_mp4(fname, fps=fps, duration=duration, context=context) 352 + def as_video(self, to_file=None, fps=10, duration=None, 353 + mime_type=None, file_type=None, context=None, verbose=False): 354 + if file_type is None and mime_type is None: 355 + if to_file is None or '.' not in str(to_file): 356 + file_type = 'mp4' 357 + else: 358 + file_type = str(to_file).split('.')[-1] 359 + if file_type is None: 360 + file_type = mime_type.split('/')[-1] 361 + elif mime_type is None: 362 + mime_type = f'video/{file_type}' 363 + frames = self.as_animation_frames( 364 + fps=fps, duration=duration, context=context) 365 + return video.RasterVideo.from_frames( 366 + frames, to_file=to_file, fps=fps, mime_type=mime_type, 367 + file_type=file_type, verbose=verbose) 368 + def as_gif(self, to_file=None, fps=10, duration=None, context=None, 369 + verbose=False): 370 + return self.as_video( 371 + to_file=to_file, fps=fps, duration=duration, context=context, 372 + mime_type='image/gif', file_type='gif', verbose=verbose) 373 + def as_mp4(self, to_file=None, fps=10, duration=None, context=None, 374 + verbose=False): 375 + return self.as_video( 376 + to_file=to_file, fps=fps, duration=duration, context=context, 377 + mime_type='video/mp4', file_type='mp4', verbose=verbose) 329 378 def _repr_svg_(self): 330 379 '''Display in Jupyter notebook.''' 331 380 return self.as_svg(randomize_ids=True)
+1 -1
drawsvg/frame_animation.py
··· 84 84 ``` 85 85 ''' 86 86 return FrameAnimationContext(draw_func=draw_func, out_file=out_file, 87 - jupyter=jupyter, video_args=video_args) 87 + jupyter=jupyter, video_args=video_args) 88 88 89 89 90 90 def frame_animate_jupyter(draw_func=None, pause=False, clear=True, delay=0.1,
+10 -2
drawsvg/jupyter.py
··· 1 1 import dataclasses 2 2 3 3 from . import url_encode 4 + from . import raster 4 5 5 6 7 + class _Rasterizable: 8 + def rasterize(self, to_file=None): 9 + if to_file is not None: 10 + return raster.Raster.from_svg_to_file(self.svg, to_file) 11 + else: 12 + return raster.Raster.from_svg(self.svg) 13 + 6 14 @dataclasses.dataclass 7 - class JupyterSvgInline: 15 + class JupyterSvgInline(_Rasterizable): 8 16 '''Jupyter-displayable SVG displayed inline on the Jupyter web page.''' 9 17 svg: str 10 18 def _repr_html_(self): 11 19 return self.svg 12 20 13 21 @dataclasses.dataclass 14 - class JupyterSvgImage: 22 + class JupyterSvgImage(_Rasterizable): 15 23 '''Jupyter-displayable SVG displayed within an img tag on the Jupyter web 16 24 page. 17 25 '''
+1 -4
drawsvg/native_animation/synced_animation.py
··· 194 194 timeline.extend(times, values) 195 195 196 196 def interpolate_at_time(self, at_time): 197 - r = { 197 + return { 198 198 name: timeline.interpolate_at_time(at_time) 199 199 for name, timeline in self.attr_timelines.items() 200 200 } 201 - print(r) 202 - return r 203 201 204 202 def _timelines_adjusted_for_context(self, lcontext=None): 205 203 all_timelines = dict(self.attr_timelines) ··· 255 253 256 254 257 255 def linear_interpolate_value(times, values, at_time): 258 - print(times, values, at_time) 259 256 if len(times) == 0: 260 257 return 0 261 258 idx = sum(t <= at_time for t in times)
+105 -15
drawsvg/video.py
··· 1 - try: 2 - import numpy as np 3 - import imageio 4 - except ImportError as e: 5 - raise ImportError( 6 - 'Optional dependencies not installed. ' 7 - 'Install with `python3 -m pip install "drawsvg[raster]"' 8 - ) from e 1 + import base64 2 + import shutil 3 + import tempfile 9 4 10 - from .drawing import Drawing 5 + def delay_import_np_imageio(): 6 + try: 7 + import numpy as np 8 + import imageio 9 + except ImportError as e: 10 + raise ImportError( 11 + 'Optional dependencies not installed. ' 12 + 'Install with `python3 -m pip install "drawsvg[all]"` ' 13 + 'or `python3 -m pip install "drawsvg[raster]"`. ' 14 + 'See https://github.com/cduck/drawsvg#full-feature-install ' 15 + 'for more details.' 16 + ) from e 17 + return np, imageio 18 + 19 + from .url_encode import bytes_as_data_uri 20 + 21 + 22 + class RasterVideo: 23 + def __init__(self, video_data=None, video_file=None, *, _file_handle=None, 24 + mime_type='video/mp4'): 25 + self.video_data = video_data 26 + self.video_file = video_file 27 + self._file_handle = _file_handle 28 + self.mime_type = mime_type 29 + def save_video(self, fname): 30 + with open(fname, 'wb') as f: 31 + if self.video_file is not None: 32 + with open(self.video_file, 'rb') as source: 33 + shutil.copyfileobj(source, f) 34 + else: 35 + f.write(self.video_data) 36 + @staticmethod 37 + def from_frames(svg_or_raster_frames, to_file=None, fps=10, *, 38 + mime_type='video/mp4', file_type=None, _file_handle=None, 39 + video_args=None, verbose=False): 40 + if file_type is None: 41 + file_type = mime_type.split('/')[-1] 42 + if to_file is None: 43 + # Create temp file for video 44 + _file_handle = tempfile.NamedTemporaryFile(suffix='.'+file_type) 45 + to_file = _file_handle.name 46 + if video_args is None: 47 + video_args = {} 48 + if file_type == 'gif': 49 + video_args.setdefault('duration', 1/fps) 50 + else: 51 + video_args.setdefault('fps', fps) 52 + save_video( 53 + svg_or_raster_frames, to_file, format=file_type, 54 + verbose=verbose, **video_args) 55 + return RasterVideo( 56 + video_file=to_file, _file_handle=_file_handle, 57 + mime_type=mime_type) 58 + def _repr_png_(self): 59 + if self.mime_type.startswith('image/'): 60 + return self._as_bytes() 61 + return None 62 + def _repr_html_(self): 63 + data_uri = self.as_data_uri() 64 + if self.mime_type.startswith('video/'): 65 + return (f'<video controls style="max-width:100%">' 66 + f'<source src="{data_uri}" type="{self.mime_type}">' 67 + f'Video unsupported.</video>') 68 + return None 69 + def _repr_mimebundle_(self, include=None, exclude=None): 70 + b64 = base64.b64encode(self._as_bytes()) 71 + return {self.mime_type: b64}, {} 72 + def as_data_uri(self): 73 + return bytes_as_data_uri(self._as_bytes(), mime=self.mime_type) 74 + def _as_bytes(self): 75 + if self.video_data: 76 + return self.video_data 77 + else: 78 + try: 79 + with open(self.video_file, 'rb') as f: 80 + return f.read() 81 + except TypeError: 82 + self.video_file.seek(0) 83 + return self.video_file.read() 11 84 12 85 13 86 def render_svg_frames(frames, align_bottom=False, align_right=False, 14 - bg=(255,)*4, **kwargs): 15 - arr_frames = [imageio.imread(d.rasterize().pngData) 16 - for d in frames] 87 + bg=(255,)*4, verbose=False, **kwargs): 88 + np, imageio = delay_import_np_imageio() 89 + if verbose: 90 + print(f'Rendering {len(frames)} frames: ', end='', flush=True) 91 + arr_frames = [] 92 + for i, f in enumerate(frames): 93 + if verbose: 94 + print(f'{i} ', end='', flush=True) 95 + if hasattr(f, 'rasterize'): 96 + png_data = f.rasterize().png_data 97 + elif hasattr(f, 'png_data'): 98 + png_data = f.png_data 99 + else: 100 + png_data = f 101 + im = imageio.imread(png_data) 102 + arr_frames.append(im) 17 103 max_width = max(map(lambda arr:arr.shape[1], arr_frames)) 18 104 max_height = max(map(lambda arr:arr.shape[0], arr_frames)) 19 105 ··· 33 119 return new_arr 34 120 return list(map(mod_frame, arr_frames)) 35 121 36 - def save_video(frames, file, **kwargs): 122 + def save_video(frames, file, verbose=False, **kwargs): 37 123 ''' 38 124 Save a series of drawings as a GIF or video. 39 125 ··· 52 138 **kwargs: Other arguments to imageio.mimsave(). 53 139 54 140 ''' 55 - if isinstance(frames[0], Drawing): 56 - frames = render_svg_frames(frames, **kwargs) 141 + np, imageio = delay_import_np_imageio() 142 + if not isinstance(frames[0], np.ndarray): 143 + frames = render_svg_frames(frames, verbose=verbose, **kwargs) 57 144 kwargs.pop('align_bottom', None) 58 145 kwargs.pop('align_right', None) 59 146 kwargs.pop('bg', None) 147 + if verbose: 148 + print() 149 + print(f'Converting to video') 60 150 imageio.mimsave(file, frames, **kwargs)
+2
setup.py
··· 36 36 'cairoSVG~=2.3', 37 37 'numpy~=1.16', 38 38 'imageio~=2.5', 39 + 'imageio_ffmpeg~=0.4', 39 40 ], 40 41 'color': [ 41 42 'pwkit~=1.0', ··· 45 46 'cairoSVG~=2.3', 46 47 'numpy~=1.16', 47 48 'imageio~=2.5', 49 + 'imageio_ffmpeg~=0.4', 48 50 'pwkit~=1.0', 49 51 ], 50 52 },