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

Add animation freeze_frame_at option

authored by cduck.me and committed by

Casey Duckering e630d351 6dc82561

+51 -17
+49 -17
drawsvg/native_animation/synced_animation.py
··· 2 2 3 3 import dataclasses 4 4 from collections import defaultdict 5 + from numbers import Number 5 6 6 7 from .. import elements, types 7 8 from . import playback_control_ui, playback_control_js ··· 15 16 end_delay: float = 0 16 17 repeat_count: Union[int, str] = 'indefinite' 17 18 fill: str = 'freeze' 19 + freeze_frame_at: Optional[float] = None 18 20 19 21 # Playback controls 20 22 show_playback_progress: bool = False ··· 93 95 self, controls_width=width, controls_x=x, controls_center_y=y, 94 96 controls_js=js) 95 97 98 + def override_args(self, args, lcontext): 99 + if (self.freeze_frame_at is not None 100 + and hasattr(lcontext.element, 'animation_data')): 101 + args = dict(args) 102 + data = lcontext.element.animation_data 103 + args.update(data.interpolate_at_time(self.freeze_frame_at)) 104 + return args 105 + 96 106 97 107 @dataclasses.dataclass 98 108 class AnimatedAttributeTimeline: ··· 119 129 raise ValueError('out-of-order key frame times') 120 130 self.times.extend(times) 121 131 self.values.extend(values) 132 + 133 + def interpolate_at_time(self, at_time): 134 + return linear_interpolate_value(self.times, self.values, at_time) 122 135 123 136 def as_animate_element(self, config: Optional[SyncedAnimationConfig]=None): 124 137 if config is not None: ··· 180 193 self.attr_timelines[attr] = timeline 181 194 timeline.extend(times, values) 182 195 196 + def interpolate_at_time(self, at_time): 197 + r = { 198 + name: timeline.interpolate_at_time(at_time) 199 + for name, timeline in self.attr_timelines.items() 200 + } 201 + print(r) 202 + return r 203 + 183 204 def _timelines_adjusted_for_context(self, lcontext=None): 184 205 all_timelines = dict(self.attr_timelines) 185 206 if lcontext is not None and lcontext.context.invert_y: ··· 212 233 yvalues = [lcontext.element.args.get('y', 0)] 213 234 if y_timeline is not None or height_timeline is not None: 214 235 ytimes, yvalues = _merge_timeline_inverted_y_values( 215 - ytimes, yvalues, htimes, hvalues) 236 + ytimes, yvalues, htimes, hvalues, 237 + linear_interpolate_value, linear_interpolate_value) 216 238 if ytimes is not None: 217 239 y_timeline = AnimatedAttributeTimeline( 218 240 'y', y_attrs, ytimes, yvalues) ··· 220 242 return all_timelines 221 243 222 244 def children_with_context(self, lcontext=None): 245 + if (lcontext is not None 246 + and lcontext.context.animation_config is not None 247 + and lcontext.context.animation_config.freeze_frame_at 248 + is not None): 249 + return [] # Don't animate if frame is frozen 223 250 all_timelines = self._timelines_adjusted_for_context(lcontext) 224 251 return [ 225 252 timeline.as_animate_element(lcontext.context.animation_config) ··· 227 254 ] 228 255 229 256 230 - def _merge_timeline_inverted_y_values(ytimes, yvalues, htimes, hvalues): 257 + def linear_interpolate_value(times, values, at_time): 258 + print(times, values, at_time) 259 + if len(times) == 0: 260 + return 0 261 + idx = sum(t <= at_time for t in times) 262 + if idx >= len(times): 263 + return values[-1] 264 + elif idx <= 0: 265 + return values[0] 266 + elif at_time == times[idx-1]: 267 + return values[idx-1] 268 + elif isinstance(values[idx], Number) and isinstance(values[idx-1], Number): 269 + fraction = (at_time-times[idx-1]) / (times[idx]-times[idx-1]) 270 + return values[idx-1] * (1-fraction) + (values[idx] * fraction) 271 + else: 272 + return values[idx-1] 273 + 274 + def _merge_timeline_inverted_y_values(ytimes, yvalues, htimes, hvalues, 275 + yinterpolate, hinterpolate): 231 276 if len(yvalues) == 1: 232 277 try: 233 278 return htimes, [-yvalues[0]-h for h in hvalues] ··· 243 288 return ytimes, [-y-h for y, h in zip(yvalues, hvalues)] 244 289 except TypeError: 245 290 return None, None 246 - def interpolate(times, values, at_time): 247 - if len(times) == 0: 248 - return 0 249 - idx = sum(t <= at_time for t in times) 250 - if idx >= len(times): 251 - return values[-1] 252 - elif idx <= 0: 253 - return values[0] 254 - elif at_time == times[idx-1]: 255 - return values[idx-1] 256 - else: 257 - fraction = (at_time-times[idx-1]) / (times[idx]-times[idx-1]) 258 - return values[idx-1] * (1-fraction)+ (values[idx] * fraction) 259 291 try: 260 292 # Offset y-value by height if invert_y 261 293 # Merge key_times for y and height animations ··· 267 299 yt = ytimes[0] if len(ytimes) else inf 268 300 while ht < inf and yt < inf: 269 301 if yt < ht: 270 - h_val = interpolate(htimes, hvalues, yt) 302 + h_val = hinterpolate(htimes, hvalues, yt) 271 303 new_times.append(yt) 272 304 new_values.append(-yvalues[yi] - h_val) 273 305 yi += 1 274 306 elif ht < yt: 275 - y_val = interpolate(ytimes, yvalues, ht) 307 + y_val = yinterpolate(ytimes, yvalues, ht) 276 308 new_times.append(ht) 277 309 new_values.append(-y_val - hvalues[hi]) 278 310 hi += 1
+2
drawsvg/types.py
··· 146 146 147 147 def write_tag_args(self, args, output_file, id_map=None): 148 148 '''Called by an element during SVG output of its tag.''' 149 + if self.context.animation_config is not None: 150 + args = self.context.animation_config.override_args(args, self) 149 151 self.context._write_tag_args( 150 152 self.context.override_args(args), output_file, id_map=id_map) 151 153