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

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

Changed files
+172 -26
drawsvg
+53 -4
drawsvg/drawing.py
···
import string
import xml.sax.saxutils as xml
-
from . import Raster
-
from . import types, elements as elements_module, jupyter
+
from . import types, elements as elements_module, raster, video, jupyter
XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n'
···
self.rasterize(to_file=fname, context=context)
def rasterize(self, to_file=None, context=None):
if to_file is not None:
-
return Raster.from_svg_to_file(
+
return raster.Raster.from_svg_to_file(
self.as_svg(context=context), to_file)
else:
-
return Raster.from_svg(self.as_svg(context=context))
+
return raster.Raster.from_svg(self.as_svg(context=context))
+
def as_animation_frames(self, fps=10, duration=None, context=None):
+
'''Returns a list of synced animation frames that can be converted to a
+
video.'''
+
if context is None:
+
context = self.context
+
if duration is None and context.animation_config is not None:
+
duration = context.animation_config.duration
+
if duration is None:
+
raise ValueError('unknown animation duration, specify duration')
+
frames = []
+
for i in range(int(duration * fps + 1)):
+
time = i / fps
+
frame_context = dataclasses.replace(
+
context,
+
animation_config=dataclasses.replace(
+
context.animation_config,
+
freeze_frame_at=time,
+
show_playback_controls=False))
+
frames.append(self.display_inline(context=frame_context))
+
return frames
+
def save_gif(self, fname, fps=10, duration=None, context=None):
+
self.as_gif(fname, fps=fps, duration=duration, context=context)
+
def save_mp4(self, fname, fps=10, duration=None, context=None):
+
self.as_mp4(fname, fps=fps, duration=duration, context=context)
+
def as_video(self, to_file=None, fps=10, duration=None,
+
mime_type=None, file_type=None, context=None, verbose=False):
+
if file_type is None and mime_type is None:
+
if to_file is None or '.' not in str(to_file):
+
file_type = 'mp4'
+
else:
+
file_type = str(to_file).split('.')[-1]
+
if file_type is None:
+
file_type = mime_type.split('/')[-1]
+
elif mime_type is None:
+
mime_type = f'video/{file_type}'
+
frames = self.as_animation_frames(
+
fps=fps, duration=duration, context=context)
+
return video.RasterVideo.from_frames(
+
frames, to_file=to_file, fps=fps, mime_type=mime_type,
+
file_type=file_type, verbose=verbose)
+
def as_gif(self, to_file=None, fps=10, duration=None, context=None,
+
verbose=False):
+
return self.as_video(
+
to_file=to_file, fps=fps, duration=duration, context=context,
+
mime_type='image/gif', file_type='gif', verbose=verbose)
+
def as_mp4(self, to_file=None, fps=10, duration=None, context=None,
+
verbose=False):
+
return self.as_video(
+
to_file=to_file, fps=fps, duration=duration, context=context,
+
mime_type='video/mp4', file_type='mp4', verbose=verbose)
def _repr_svg_(self):
'''Display in Jupyter notebook.'''
return self.as_svg(randomize_ids=True)
+1 -1
drawsvg/frame_animation.py
···
```
'''
return FrameAnimationContext(draw_func=draw_func, out_file=out_file,
-
jupyter=jupyter, video_args=video_args)
+
jupyter=jupyter, video_args=video_args)
def frame_animate_jupyter(draw_func=None, pause=False, clear=True, delay=0.1,
+10 -2
drawsvg/jupyter.py
···
import dataclasses
from . import url_encode
+
from . import raster
+
class _Rasterizable:
+
def rasterize(self, to_file=None):
+
if to_file is not None:
+
return raster.Raster.from_svg_to_file(self.svg, to_file)
+
else:
+
return raster.Raster.from_svg(self.svg)
+
@dataclasses.dataclass
-
class JupyterSvgInline:
+
class JupyterSvgInline(_Rasterizable):
'''Jupyter-displayable SVG displayed inline on the Jupyter web page.'''
svg: str
def _repr_html_(self):
return self.svg
@dataclasses.dataclass
-
class JupyterSvgImage:
+
class JupyterSvgImage(_Rasterizable):
'''Jupyter-displayable SVG displayed within an img tag on the Jupyter web
page.
'''
+1 -4
drawsvg/native_animation/synced_animation.py
···
timeline.extend(times, values)
def interpolate_at_time(self, at_time):
-
r = {
+
return {
name: timeline.interpolate_at_time(at_time)
for name, timeline in self.attr_timelines.items()
}
-
print(r)
-
return r
def _timelines_adjusted_for_context(self, lcontext=None):
all_timelines = dict(self.attr_timelines)
···
def linear_interpolate_value(times, values, at_time):
-
print(times, values, at_time)
if len(times) == 0:
return 0
idx = sum(t <= at_time for t in times)
+105 -15
drawsvg/video.py
···
-
try:
-
import numpy as np
-
import imageio
-
except ImportError as e:
-
raise ImportError(
-
'Optional dependencies not installed. '
-
'Install with `python3 -m pip install "drawsvg[raster]"'
-
) from e
+
import base64
+
import shutil
+
import tempfile
-
from .drawing import Drawing
+
def delay_import_np_imageio():
+
try:
+
import numpy as np
+
import imageio
+
except ImportError as e:
+
raise ImportError(
+
'Optional dependencies not installed. '
+
'Install with `python3 -m pip install "drawsvg[all]"` '
+
'or `python3 -m pip install "drawsvg[raster]"`. '
+
'See https://github.com/cduck/drawsvg#full-feature-install '
+
'for more details.'
+
) from e
+
return np, imageio
+
+
from .url_encode import bytes_as_data_uri
+
+
+
class RasterVideo:
+
def __init__(self, video_data=None, video_file=None, *, _file_handle=None,
+
mime_type='video/mp4'):
+
self.video_data = video_data
+
self.video_file = video_file
+
self._file_handle = _file_handle
+
self.mime_type = mime_type
+
def save_video(self, fname):
+
with open(fname, 'wb') as f:
+
if self.video_file is not None:
+
with open(self.video_file, 'rb') as source:
+
shutil.copyfileobj(source, f)
+
else:
+
f.write(self.video_data)
+
@staticmethod
+
def from_frames(svg_or_raster_frames, to_file=None, fps=10, *,
+
mime_type='video/mp4', file_type=None, _file_handle=None,
+
video_args=None, verbose=False):
+
if file_type is None:
+
file_type = mime_type.split('/')[-1]
+
if to_file is None:
+
# Create temp file for video
+
_file_handle = tempfile.NamedTemporaryFile(suffix='.'+file_type)
+
to_file = _file_handle.name
+
if video_args is None:
+
video_args = {}
+
if file_type == 'gif':
+
video_args.setdefault('duration', 1/fps)
+
else:
+
video_args.setdefault('fps', fps)
+
save_video(
+
svg_or_raster_frames, to_file, format=file_type,
+
verbose=verbose, **video_args)
+
return RasterVideo(
+
video_file=to_file, _file_handle=_file_handle,
+
mime_type=mime_type)
+
def _repr_png_(self):
+
if self.mime_type.startswith('image/'):
+
return self._as_bytes()
+
return None
+
def _repr_html_(self):
+
data_uri = self.as_data_uri()
+
if self.mime_type.startswith('video/'):
+
return (f'<video controls style="max-width:100%">'
+
f'<source src="{data_uri}" type="{self.mime_type}">'
+
f'Video unsupported.</video>')
+
return None
+
def _repr_mimebundle_(self, include=None, exclude=None):
+
b64 = base64.b64encode(self._as_bytes())
+
return {self.mime_type: b64}, {}
+
def as_data_uri(self):
+
return bytes_as_data_uri(self._as_bytes(), mime=self.mime_type)
+
def _as_bytes(self):
+
if self.video_data:
+
return self.video_data
+
else:
+
try:
+
with open(self.video_file, 'rb') as f:
+
return f.read()
+
except TypeError:
+
self.video_file.seek(0)
+
return self.video_file.read()
def render_svg_frames(frames, align_bottom=False, align_right=False,
-
bg=(255,)*4, **kwargs):
-
arr_frames = [imageio.imread(d.rasterize().pngData)
-
for d in frames]
+
bg=(255,)*4, verbose=False, **kwargs):
+
np, imageio = delay_import_np_imageio()
+
if verbose:
+
print(f'Rendering {len(frames)} frames: ', end='', flush=True)
+
arr_frames = []
+
for i, f in enumerate(frames):
+
if verbose:
+
print(f'{i} ', end='', flush=True)
+
if hasattr(f, 'rasterize'):
+
png_data = f.rasterize().png_data
+
elif hasattr(f, 'png_data'):
+
png_data = f.png_data
+
else:
+
png_data = f
+
im = imageio.imread(png_data)
+
arr_frames.append(im)
max_width = max(map(lambda arr:arr.shape[1], arr_frames))
max_height = max(map(lambda arr:arr.shape[0], arr_frames))
···
return new_arr
return list(map(mod_frame, arr_frames))
-
def save_video(frames, file, **kwargs):
+
def save_video(frames, file, verbose=False, **kwargs):
'''
Save a series of drawings as a GIF or video.
···
**kwargs: Other arguments to imageio.mimsave().
'''
-
if isinstance(frames[0], Drawing):
-
frames = render_svg_frames(frames, **kwargs)
+
np, imageio = delay_import_np_imageio()
+
if not isinstance(frames[0], np.ndarray):
+
frames = render_svg_frames(frames, verbose=verbose, **kwargs)
kwargs.pop('align_bottom', None)
kwargs.pop('align_right', None)
kwargs.pop('bg', None)
+
if verbose:
+
print()
+
print(f'Converting to video')
imageio.mimsave(file, frames, **kwargs)
+2
setup.py
···
'cairoSVG~=2.3',
'numpy~=1.16',
'imageio~=2.5',
+
'imageio_ffmpeg~=0.4',
],
'color': [
'pwkit~=1.0',
···
'cairoSVG~=2.3',
'numpy~=1.16',
'imageio~=2.5',
+
'imageio_ffmpeg~=0.4',
'pwkit~=1.0',
],
},