Collection of mostly command line tools / PHP scripts. Somewhat out of date.

⌈⌋ branch:  scripts + snippets


Artifact [59a61fd09e]

Artifact 59a61fd09eec612f297e6b7af38b61311d3b6fcb:

  • Executable file inkscape/export_gif.py — part of check-in [58335bd8d7] at 2023-05-23 10:10:47 on branch trunk — prepare potential all-merge mode (user: mario size: 36032)

#!/usr/bin/env python
# encoding: utf-8
# api: inkscape
##type: effect
# category: export
# title: GIF slideshow
# description: Export and combine layers as animation via ImageMagick/Pillow
# id: org.include-once.inkscape.export-gif
# license: MITL
# version: 1.1
# state: stable
# depends: bin:inkscape (>= 1.1), bin:convert, python (>= 3.6)
# suggests: python:svgelements >= 1.8
# pylint: disable=line-too-long, missing-module-docstring, bad-whitespace
# config:
#    { name: file, type: file, mode: file, value: "~/anim.gif", description: "Target GIF filename", help: "Output filename should end in .gif for actual results. Alternatively can be `APNG:dir/filename.apng` with ImageMagick 7." }
#    { name: mode, type: select, select: "PNG→ImageMagick (better quality)|SVG→ImageMagick (simple drawings)|PNG→Pillow (builtin - a bit faster)|JavaScript→SVG (embed anim code)", value: PNG+ImageMagick, description: Conversion mode, help: "There's different backends to assemble the GIF. Pillow also works on Windows without convert.exe installed. The JavaScript mode embeds some code, does not actually generate a GIF output file." }
#    { name: delay, type: float, value: 0.35, min: 0.01, max: 20, precision: 2, description: "Delay between slides (seconds)", "Can be overriden on a per-slide basis with [--delay=2.5]. Note that convert(1) usually uses »ticks« (*10ms), but this export_gif option is really in seconds." }
#    { name: loop, type: int, value: 0, description: "Loop limit (0 for endless)" }
#    [ hidden: 1, disabled: save_as, type: str, value: "0", description: used in saveas_gif.inx, note: "have to use `str` here, cause .inx `false` is not recognized for hidden attributes" ]
#    { xml: <image width="505" height="50">export_gif.svg</image> }
#    { name: notebook, type: notebook, value: system, description: "Additional flags and help" }
#    { category: ImageMagick, name: fuzz, type: select, select: "0%|5%|10%|20%|30%|50%", value: "10%", description: "Fuzzing/dither", help: "Detect similar colors for optimization" }
#    { category: ImageMagick, name: layers, type: select, select: "optimize|coalesce|compare-any|compare-clear|compare-overlay|composite|dispose|flatten|merge|mosaic|optimize-frame|optimize-plus|optimize-transparency|remove-dups|remove-zero|trim-bounds", value: "optimize", description: "Accumulation/combination of -layers", help: "Can influence optimization or layer combination. Specifically coalesce/merge are pre-interpreted by export_gif to produce accumulating slides." }
#    { category: ImageMagick, name: extra, type: select, select: "-quiet|-optimize|-size 640x360|-alpha background|-auto-gamma|-auto-level|-coalesce|-colors 64|-dither FloydSteinberg|-limit disk 1MB|-reverse|-monochrome|-transparent white", value: "", description: "Extra args", help: "Some default convert -args." }
#    { category: ImageMagick, name: extra2, type: str, value: "", description: "Custom args", help: "Specify additional convert(1) options." }
#    { category: ImageMagick, name: background, type: color, appearance: colorbutton, value: "0", description: "Background color for transparent layers", help: "Only works for SVG→ImageMagick option." }
#    { category: System, name: preview, type: bool, value: 0, description: "Preview result file", help: "Should bring up default image viewer on resulting GIF (via xdg-open, or start… on Windows)" }
#    { category: System, name: keep_tmp, type: bool, value: 0, description: "Keep temporary files", help: "Will retain the frame PNGs in the fixed directory /tmp/inkscape.export_gif/" }
#    { category: System, name: reload_svg, type: bool, value: 0, description: "Reload SVG in Inkscape", help: "Averts the warning popup of lacking result data. But is quite redundant for this tool. And should only be enabled if it's becoming too obnoxious." }
#    { category: System, name: export_background, type: bool, value: 0, description: "Force background application in PNG export", help: "Uses --export-background for generating PNG slides prior assembly. (See ImageMagick for color tab.)" }
#    { category: System, name: default_merge, type: bool, value: 0, description: "Default all layers to [merge] - not implemented yet", help: "Requires to annotate actual slides with [background] or [export], because it defaults everything else to down-grouping [merge]" }
#    { category: Animation, name: subframes, type: int, value: 5, description: "Subframes per ❮animation❯ slide", help: "Can be overridden per [animate=25] or [steps=25] in layer label." }
#    { category: Animation, name: a_rotate, type: bool, value: 1, description: "Use simpler rotate() handler", help: "Actually works better than tweening, and allows for >90° rotations without collapsing the matrix. OTOH the inkex method might handle positioning better." }
#    { category: Animation, name: all_anim, type: bool, value: 0, description: "Engage whenever SVG animate instructions are present", help: "Makes [animate] tags redundant, at the expense of longer processing times; and repeat runs for background layers." }
#    { category: Animation, name: all_pace, type: bool, value: 0, description: "Honor timing information for all slides", help: "Honor begin=, dur=, and some calcMode= settings, for pacing or delayed timing. Otherwise individual layers can be marked with [pace] or [smooth] in addition to [animate]. Else animations run/stretch across the alloted delay time for a slide." }
#    { category: Help, label: "Layer labels (Ctrl+Shift+L, double click) can specify additional options:" }
#    { category: Help, label: " 🞂 [fixed] for very permanent foreground layer" }
#    { category: Help, label: " 🞂 [background] for sticky background images" }
#    { category: Help, label: " 🞂 [merge] enjoins partial layers; and [exclude] skips them" }
#    { category: Help, label: " 🞂 [animate=10] generates ❮animate*❯ subframes, flag: [pace] timing" }
#    { category: Help, label: " 🞂 [--delay=2.5] resets ImageMagick option (delay also for Pillow)",
#    { category: Help, label: $help, appearance: url }
# inx-export: .gif, image/gif, GIF slideshow (*.gif), Graphics Interchange Format 98a
# architecture: all
# pack: export_gif.py, *.inx, pmd2inks, animate_yo.py, svgelements.py, export_gif.svg, LICENSE=/usr/share/doc/inkscape-export-gif/copyright
# permissive: 0.5
# format: off
# author: mario#include-once:org
# url: https://inkscape.org/~culturaljuice/★export_gif
# help: https://fossil.include-once.org/scripts/wiki/inkscape
# orig: https://github.com/jespino/inkscape-export-layers
#
# Generates a GIF slideshow from image layers ☰  (Shift+Ctrl+L). The menu
# entry Extensions➜Export➔GIF-slideshow… iterates over layers to generate
# animation slides. They get merged from bottom to top. Depending on shape
# overlap or layer transparency, there's different usage modes:
#
#  🞂 With a solid background in each layer, there's no accumulation to
#    take care of.
#  🞂 Alternatively elect layers can be labeled as [background] to stick,
#    or [fixed] as permanent foreground, or [merge] for down-grouping.
#  🞂 If the -layers option is composite/coalesce/merge then all lower
#    layers remain visible until the current frame.
#  🞂 Any [animate] layers get interpolated as is, layer transparency
#    preserved.
#
# Requires ImageMagick installed; fit for standard Linux setups. But might
# work with convert.exe on Windows. And alternatively there's the builtin
# Pillow conversion method. The options mostly map to IM flags.
#
#  🞂 Without the PNG conversion step, SVG interpretation is up to Imagick,
#    and will not render fancy font/path effects. Just for plain old SVGs.
#  🞂 The [PNG→Pillow] option works without ImageMagick, but often generates
#    rougher GIFs, yet might produce smaller result files.
#  🞂 In [JavaScript] mode, no output file will be generated. It just adds a
#    script for animating slides into the current document (web views).
#  🞂 A [fixed] layer label describes a permanent foreground (until overdrawn),
#    but never constitues a frame. Whereas [background] layers only become
#    active and permanent when its frame is reached. Additionally [merge]
#    labels will enjoin partial layers to the preceding full slide. But also
#    [exclude] to omit layers entirely.
#  🞂 There's also a rudimentary [animate=5] option to craft subframes
#    from embedded <animate*> instructions. Motions require `pip install
#    svgelements` or a bundled version for accurate paths. Bonus label
#    tag [animate][pace] will honor begin= and dur= times.
#  🞂 Each layer label may also specify ImageMagick flags [--delay=2.5].
#  🞂 The "default background color" is useful for otherwise transparent
#    layers. Half-transparancy can be useful to gradually fade out lower
#    frames (only seems to work in SVG/ImageMagick -displace mode).
#  🞂 Extra arguments are just a list of sample options, might not be
#    particularly useful by themselves. Use the second input or [--flag=X]
#    labels for more refined combinations.
#  🞂 Can't run two instances in parallel, due to fixed /tmp/inkscape… dir
#  🞂 It's also available as File > Save As... option (fewer options).
#  🞂 Tested on Inkscape 1.2 (2022-05-xx) and 1.3-dev (2022-09-xx)
#
# Might require some experimentation, and heavily utilizing the preview
# option. Potential error popups become informative at the very end.
# The save as dialog ".gif" option provides fewer options, but effectively
# same behaviours for layer flags.
#
# Code is derived from Xavi's "Export Layers Redux For Inkscape 1.1+".
# <https://inkscape.org/~VagabondArcade/%E2%98%85export-layers-redux-for-inkscape-11>
#
# ZIP contains the `pmd2inks` command-line tool to ease extending the
# options dialog (using the <https://pypi.org/project/pluginconf/> format).


import sys
import copy
import os
import tempfile
from argparse import Namespace
import re
import functools
import textwrap
import shlex
import shutil
import math
import inkex
from inkex.utils import strargs
from inkex.tween import StyleInterpolator, TransformInterpolator
from inkex.transforms import Transform


class GIFExport(inkex.EffectExtension):#, inkex.OutputExtension (for export pane):#, inkex.RasterOutputExtension (only works for static PNG conversion)
    """ doubles as effect and save-as extension """
    multi_inx = True

    def __init__(self):
        """ init the effect library """
        self.tempdir = None
        self.index = 0 # current slide
        self.win32 = sys.platform == "win32"
        super().__init__()

    @staticmethod
    def add_arguments(pars):
        """ populate self.options from script args """
        pars.add_argument("--file", type=str, dest="file", default="~/anim.gif", help="Target GIF filename")
        pars.add_argument("--mode", type=str, dest="mode", default="PNG+ImageMagick", help="Conversion mode")
        pars.add_argument("--delay", type=float, dest="delay", default=0.35, help="Delay between slides (seconds)")
        pars.add_argument("--loop", type=int, dest="loop", default=0, help="Loop limit (0 for endless)")
        pars.add_argument("--notebook", type=str, dest="notebook", default="system", help="Additional flags and help")
        pars.add_argument("--fuzz", type=str, dest="fuzz", default="10%", help="Fuzzing/dither")
        pars.add_argument("--layers", type=str, dest="layers", default="optimize", help="Accumulation/combination of -layers")
        pars.add_argument("--extra", type=str, dest="extra", default="", help="Extra args")
        pars.add_argument("--extra2", type=str, dest="extra2", default="", help="Custom args")
        pars.add_argument("--background", type=inkex.Color, dest="background", default="0", help="Background color for transparent layers")
        pars.add_argument("--preview", type=inkex.Boolean, dest="preview", default=False, help="Preview result file")
        pars.add_argument("--keep_tmp", type=inkex.Boolean, dest="keep_tmp", default=False, help="Keep temporary files")
        pars.add_argument("--reload_svg", type=inkex.Boolean, dest="reload_svg", default=False, help="Reload SVG in Inkscape")
        pars.add_argument("--export_background", type=inkex.Boolean, dest="export_background", default=False, help="Force background application in PNG export")
        pars.add_argument("--default_merge", type=inkex.Boolean, dest="default_merge", default=False, help="Default all layers to [merge] - not implemented yet")
        pars.add_argument("--subframes", type=int, dest="subframes", default=5, help="Subframes per ❮animation❯ slide")
        pars.add_argument("--a_rotate", type=inkex.Boolean, dest="a_rotate", default=True, help="Use simpler rotate() handler")
        pars.add_argument("--all_anim", type=inkex.Boolean, dest="all_anim", default=False, help="Engage whenever SVG animate instructions are present")
        pars.add_argument("--all_pace", type=inkex.Boolean, dest="all_pace", default=False, help="Honor timing information for all slides")
        # only used in saveas_gif.inx (but not export_gif.inx, else hidden argument would be populated from preferences.xml history)
        pars.add_argument("--save_as", type=bool, dest="save_as", default=False, help="used in saveas_gif.inx")

    def make_temp(self):
        """ separate temp directory for easier cleanup """
        self.tempdir = tempfile.gettempdir() + "/inkscape.export_gif"
        if not os.path.exists(self.tempdir):
            os.mkdir(self.tempdir)
        if self.options.save_as:
            self.options.file = tempfile.mkstemp(".gif", "inkex")[1]

    def save(self, stream):
        """ Save-as handler for .gif (all handled in .effect() already; just redirects output as bytestream) """
        if not self.options.save_as:
            return
        with open(self.options.file, "rb") as tmp:
            stream.write(tmp.read())
        if not self.options.keep_tmp:
            os.unlink(self.options.file)

    def effect(self):
        """ Generate output file when invoked """
        layers = list(self.get_layers())
        fixed_layers = [layer.id for layer in layers if layer.is_fixed]
        temp_files_args = [] # tuples of filename,{-args}

        # more parameter handling
        if re.search("JavaScript", self.options.mode, re.I):
            return self.inject_javascript()
        self.make_temp()
        up_until_mode = re.search("merge|compo|coal|plus", self.options.layers)
        preconvert_png = re.search("PNG", self.options.mode, re.I)
        img_ext = "png" if preconvert_png else "svg"

        # traverse layers, iteratively slicing for individual .svg/.png frames
        for self.index, layer in enumerate(layers):

            if layer.no_frame: # [fixed], [exclude]
                continue

            if layer.is_sticky or up_until_mode:
                fixed_layers.append(layer.id)
            merge_layers = [
                follow.id for follow in layers[self.index+1:] if follow.do_merge
            ]

            if layer.animate or self.is_animatable(layer):
                # produces multiple subframe .pngs
                anim = AnimationSteps(
                    parent = self, layer = layer,
                    svg = self.export_layers(dest="", show=[layer.id] + fixed_layers + merge_layers)
                )
                temp_files_args.extend(list(anim.export()))
            else:
                # current frame into .svg/.png
                dest = self.export_layers(
                    dest = f"{self.tempdir}/{self.index}.{layer.label}.{img_ext}",
                    show = [layer.id] + fixed_layers + merge_layers
                )
                temp_files_args.append([dest, layer.args])

        # be done
        return self.export(temp_files_args)

    def export(self, temp_files_args):
        """ convert whole batch 0.png, 1.png, 2.png with possible -args """
        if re.search("Pillow", self.options.mode, re.I):
            self.pillow_gif(temp_files_args)
        else:
            self.imagick_gif(temp_files_args)

        if self.options.preview:
            os.system(('start %s' if self.win32 else 'xdg-open %r 2>&1 &') % self.options.file)

        if self.options.save_as:
            self.save(self.options.output)
        elif self.options.reload_svg:
            inkex.base.SvgOutputMixin.save(self, self.options.output)

    @staticmethod
    def hex_color(color, with_alpha=True):
        """ convert ints or inkex.Color tuple to #RRGGBBAA string """
        color = inkex.Color(color)
        formatted = "#{:02X}{:02X}{:02X}".format(*color.to_rgb())
        if with_alpha:
            formatted += "{:02X}".format(int(255 * min(1.0, color.alpha)))
        return formatted

    def convert_png(self, svg_path, png_path):
        """ Use the convenience wrapper (more likely referencing the current inkscape binary) """
        extra = {}
        if self.options.background and self.options.export_background:
            extra.update({
                "export_background": self.hex_color(self.options.background),
                "export_background_opacity": "{:.2f}".format(self.options.background.alpha),
                # 5%/compareOverlay/-coalesce per channel [--alpha=set] [--channel=A] [--ordered-dither=checks] has some effect
                # compareClear/-coalesce [--channel=A --ordered-dither=4x4 --dispose=-1]
            })
        inkex.command.inkscape(
            svg_path, export_filename=png_path, export_type="png",
            export_area_page=True, export_text_to_path=True, **extra,
            #export_dpi=96, --export-width  --export-height
        )

    def imagick_gif(self, temp_files_args):
        """ Run `convert` using list of exported slides """
        args = [
            "-loop", self.options.loop,
            "-layers", self.options.layers,
            *shlex.split(self.options.extra),
            *shlex.split(self.options.extra2),
        ]
        slide_params = {   # some args are per-input file
            "delay": self.options.delay,
            "background": self.hex_color(self.options.background) if self.options.background else "None",
            "fuzz": self.options.fuzz,
            **dict(re.findall(r"-(alpha|blur|channel|colors|compose|cycle|fx|gamma|grayscale|ordered-dither|resample|resize|transparent)\s+(\w+\S+)(?=\s+[^-]|\s*$)", self.options.extra2))
        }
        # zip frame filenames + extra params
        for filename, arg_list in temp_files_args:
            for key, val in {**slide_params, **arg_list}.items():
                if key == "write":
                    continue
                if key == "delay":
                    val = round(float(val) * 100)
                args.extend(["-" + key.strip("-"), val])
            args.extend([filename])
        # execute
        inkex.command.call(
            shutil.which("magick") or "convert",
            *args,
            os.path.expanduser(self.options.file)
        )

    def pillow_gif(self, temp_files_args):
        """ Alternative: use PIL to assemble GIF (very rough, only works with PNG preconvert) """
        from PIL import Image # pylint: disable=import-outside-toplevel
        frames = [Image.open(fn_ar[0]) for fn_ar in temp_files_args]
        # https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#saving
        frames[0].save(
            os.path.expanduser(self.options.file),
            save_all = True,
            append_images = frames[1:],
            version = "GIF89a",
            optimize = True,
            disposal = 1, # no disposal
            duration = [float(args[1].get("delay", self.options.delay)) * 1000 for args in temp_files_args],
            loop = self.options.loop,
        )

    def clean_up(self):
        """ remove tmp files + dir, automatically invoked by base.run() """
        super().clean_up()
        if self.options.keep_tmp or not self.tempdir or not os.path.exists(self.tempdir):
            return
        for file_name in os.listdir(self.tempdir):
            os.unlink(self.tempdir + "/" + file_name)
        os.rmdir(self.tempdir)

    def is_animatable(self, layer):
        """ Check for presence of <animate*> tags in current frame """
        if self.options.all_anim:
            if self.svg.xpath(f"""count(//svg:g[@id="{layer.id}"]//*[starts-with(local-name(),"animate")])"""): # `0.0` if none
                layer.animate = int(self.options.subframes)
                return True
        return False

    def export_layers(self, dest, show):
        """
        Export selected layers of SVG to the `dest` file. Implicitly converting if requested.
        :arg  str     dest:  path to export SVG or PNG file.
        :arg  list    show:  layers to show. each element is a string.
        """
        svg = copy.deepcopy(self.svg) # always work on copy, in case of `cut` mode
        dest_png = dest.endswith(".png")

        for layer in svg.xpath('./svg:g[@inkscape:groupmode="layer"][@inkscape:label]'):
            layer_id = layer.attrib["id"]

            if show == True or layer_id in show: # pylint: disable=singleton-comparison
                layer.attrib['style'] = 'display:inline'
            else:
                layer.getparent().remove(layer)

        if not dest:
            return svg
        if dest_png:
            dest_png, dest = dest, f"{dest[0:-4]}.svg"
        with open(dest, "wb") as svg_fp:
            svg_fp.write(svg.tostring())
        if dest_png:
            self.convert_png(dest, dest_png)
        return dest_png or dest

    def get_layers(self):
        """ Per default all layers are exported, label tags [bg], [exclude], [merge] yield special treatment """
        for layer in self.svg.xpath('./svg:g[@inkscape:groupmode="layer"][@inkscape:label]'):

            label = layer.attrib["{http://www.inkscape.org/namespaces/inkscape}label"]
            animate = re.findall(r"\[(?:steps|animate)(?:=(\d+))?\]", label)
            
            # all-merge mode?
            is_export = bool(re.search(r"background|fixed|\[(bg|export|foreground)\]", label)),
            # no_frame = … or self.options.default_merge
            # do_merge = self.options.default_merge and not is_export

            yield Namespace(
                id = layer.attrib["id"],
                label = re.sub(r"\W+", "_", label),
                is_fixed = bool(re.search(r"fixed|\[(is_?fixe?d?|foreground)\]", label)),
                is_sticky = bool(re.search(r"background|\[(bg)\]", label)),
                no_frame = bool(re.search(r"fixed|\[(merge|no_frame|exclude)\]", label)),
                do_merge = bool(re.search(r"\[(merge|join|group)\]", label)),
                animate = int(([i for i in animate if i] or [self.options.subframes])[0]) if animate else 0,
                tags = re.findall(r"\[(\w+)\]", label),
                args = dict(re.findall(r"--(\w[\w\-]+)=(\w[^\s,;\]\)]*)", label))
            )

    def inject_javascript(self):
        """ insert JavaScript version into SVG document """
        script = inkex.Script()
        for node in self.svg.xpath("//svg:script[@id='export_gif']"):
            script = node
        script.set("id", "export_gif")
        script.set_text(textwrap.dedent("""
            /* rudimentary JS-version of export_gif */
            function slides(e) {
                var svg = e.target;
                var layers = []
                for (var layer of svg.getElementsByTagName("g")) {
                    if (layer.getAttribute("inkscape:groupmode") == "layer") {
                        layers.push(layer)
                    }
                }
                function show_slide(slide) {
                    var last_layer = 0;
                    var i = 0;
                    for (var layer of layers) {
                        var label = layer.getAttribute("inkscape:label")
                        if ((i == slide) || label.match(/background|bg|fixed/)) {
                            layer.setAttribute("style", "display:inline")
                        }
                        else {
                            layer.setAttribute("style", "display:none")
                        }
                        if (!label.match(/fixed/)) {
                            last_layer = i
                            i = i + 1
                        }
                    }
                    if (slide >= last_layer) {
                        return 0;
                    }
                    return slide + 1;
                }
                var slide = show_slide(0)
                setInterval(function(){
                    slide = show_slide(slide);
                }, """ + str(self.options.delay*1000) + """);
            }"""))
        self.svg.append(script)
        self.svg.set(
            "onload",
            re.sub(
                r"(;\s*)?\bslides\(.*?\);?|$", # replace or append
                f"\\1slides(evt);",
                self.svg.attrib.get("onload", ""),
                1
            )
        )
        # return modified SVG tree
        inkex.base.SvgOutputMixin.save(self, self.options.output)

def animate_with_values(func):
    """
    Decorator: unpacks values=1;2;3 into to= invocation of chained apply() steps.
    The time= parameter is evenly split across partial applicators - the assumption
    being that pace() already coralls keyTimes= into linear intervals.
    """
    @functools.wraps(func)
    def wrapped(self, anim, **attrib):
        if "values" not in attrib:
            return func(self, anim, **attrib)

        # prepare multiple applicators, with keyTimes pre-mapped into indexable range
        values = re.split(r"\s*;\s*", attrib["values"])
        transforms = []
        for index, value in enumerate(values):
            attrib["to"] = value
            if index > 0:
                attrib["from"] = values[index - 1]
            transforms.append(func(self, anim, **attrib))

        def apply(time):
            index = math.floor( min(time, 0.999) * len(transforms) )
            transforms[index](time)
        return apply

    return wrapped

class AnimationSteps(): # pylint: disable=invalid-name, unused-argument, import-outside-toplevel
    """
    Try to interpolate some frames from <animate*> interpretations.
    This won't progress beyond the most rudimentary of implementions.
    Currently color changes, scaling, rotation, some moving (proper
    path traversal hinges on svgelements).

    doc: https://edutechwiki.unige.ch/en/Using_Inkscape_for_web_animation,
       https://wiki.inkscape.org/wiki/index.php/SVG_Animation
    """

    def __init__(self, parent, svg, layer):
        """
        Inherit pre-filtered svg copy. layer flags
        :arg  GifExport  parent:  GifExport main loop and export callbacks
        :arg  ETree      svg:     duplicated and pre-filtered document
        :arg  Namespace  layer:   current layer flags
        """
        self.gif = parent
        self.convert_png = parent.convert_png
        self.svg = svg  # ← already a deepcopy
        self.layer = layer
        self.frames = layer.animate
        self.delay = float(layer.args.get("delay", self.gif.options.delay))
        self.layer.args.update({
            "delay": self.delay / self.frames   # frame time split between animation steps
        })

    def export(self):
        """ applies transforms in each subframe, yields filename+args list """
        transforms = []
        for anim in self.svg.xpath("//svg:animate"):
            transforms.append([self.animate_style(anim, **anim.attrib), self.pace(anim)])
        for anim in self.svg.xpath("//svg:animateTransform"):
            transforms.append([self.animate_transform(anim, **anim.attrib), self.pace(anim)])
        for anim in self.svg.xpath("//svg:animateMotion"):
            transforms.append([self.animate_motion(anim, **anim.attrib), self.pace(anim, smooth=True)])

        for index in range(0, self.frames):
            for apply, pace in transforms:
                if self.frames > 1:
                    apply(pace(index / (self.frames - 1))) # durations are mapped into the 0.0 - 1.0 range

            # custom export
            dest = f"{self.gif.tempdir}/{self.gif.index}+{index}.{self.layer.label}.png"
            GIFExport.export_layers(self, dest, show=True)

            yield [dest, self.layer.args]

        # reset, in case frame gets animated again ([fixed]/[bg])
        self.layer.args["delay"] = self.delay

    @animate_with_values
    def animate_style(self, anim, attributeName, to, **attrib):
        """ <animate to="#000000" attributeName="fill" /> """
        target = self.get_target(anim)
        #self.adapt(target, css={attributeName: attrib["from"]})
        transformed = self.adapt(
            copy.deepcopy(target),
            css = { attributeName: re.sub(r"^(#\w{6}).+", r"\1", to) }   # transparency #rrggbbAA isn't helping tween.Style
        )
        inter = StyleInterpolator(target, transformed)

        def apply(time, target=target, tween=inter):
            self.adapt(target, css=tween.interpolate(time))

        return apply

    #@animate_with_values
    def animate_transform(self, anim, **attrib):
        """ <animateTransform to="360 0 40" attributeName="transform" /> """
        target = self.get_target(anim)
        _type = attrib.get("type", "translate")
        defaults = {
            "rotate": "0",            # rotate works up until 90 degrees, at 180 starts to just scale
            "translate": "0,0",
            "scale": "1,1",
            "matrix": "0,0,0,0,0,0",
            "skewX": "0",
            "skewY": "0",
        }
        if _type == "rotate" and self.gif.options.a_rotate:
            return self.animate_rotate(target, anim, attrib.get("from"), attrib.get("to"))

        src = Transform(_type + "(" + attrib.get("from", defaults.get(_type, "0,0")) + ")")
        dest = Transform(_type + "(" + attrib["to"] + ")")
        inter = TransformInterpolator(src, dest)

        def apply(time, target=target, tween=inter):
            self.adapt(target, transform=tween.interpolate(time))

        return apply

    def animate_rotate(self, target, anim, start, end):
        """ simpler <animateTransform type=rotate> handler, does not fold the matrix at 180° """
        start = strargs(start) or [0]
        end = strargs(end) or [0]
        sfx = [str(coord) for coord in end[1:] + start[1:]]  # any center coordinates

        def apply(time, start=start[0], end=end[0], sfx=" ".join(sfx[0:2]), prepend=self.had_transform(target)):
            deg = start + (end - start) * time
            self.adapt(target, transform=prepend + f"rotate({deg} {sfx})")
        return apply

    def animate_motion(self, anim, path, **attrib): # pylint: disable=invalid-name
        """ <animateMotion path="M10,20" /> """
        target = self.get_target(anim)

        #if inkex.paths.Path(path)[0].is_relative:
            #bbox = target.bounding_box()
            #origin_xy = [bbox.left, bbox.top]

        try:
            import svgelements
            point = svgelements.Path(path).point
        except ModuleNotFoundError:
            point = self.rough_path(path)

        def apply(time, point=point, prepend=self.had_transform(target)):
            dx, dy = point(time)
            self.adapt(target, transform=prepend+f"translate({dx} {dy})")

        return apply

    @staticmethod
    def had_transform(target):
        """ return prependable .transform= """
        if not target.transform:
            return ""
        return str(target.transform) + " "

    @staticmethod
    def rough_path(path):
        """ traverse any points+control in a zig-zag way, length discounted, absolute coordinates, etc. """
        pairs = [
            (float(x), float(y)) for (x, y) in
            re.findall(r"(-?\d[.\d]*)[,\s]+(?:h\s*)?(-?\d[.\d]*)", path)
        ]

        def calc(r, pairs=pairs):
            coord = pairs[int(r * (len(pairs)-1))]
            return coord[0]*r, coord[1]*r

        return calc

    @staticmethod
    def adapt(elem, css=None, **kwargs):
        """ modify properties or style= settings """
        if css:
            style = inkex.Style(elem.get("style"))
            style.update(css)
            elem.set("style", str(style))
        for attr, val in kwargs.items():
            elem.set(attr, str(val))
        return elem

    def get_target(self, animation):
        """ resolve xlink or parent relation """
        if animation.attrib.get("xlink:href"): # pylint: disable=no-else-return
            return self.svg.getElementById(animation.attrib["xlink:href"][1:])
        else:
            return animation.getparent()

    # pylint: disable=function-redefined, no-else-return, bare-except
    def pace(self, anim, smooth=False):
        """
        Some relative time slice folding into linear 0.0 … 1.0 frame interval.
        Might be able to recognize some of: begin, dur, keyTimes, calcMode, etc.
        Simply chains bounding functions. Enabled on [pace] flag or global option.
        """
        attr = anim.attrib.get
        r_begin = self.seconds(attr("begin")) / self.delay
        r_end = r_begin + self.seconds(attr("dur"), self.delay) / self.delay

        def vary(r_time):
            """ no transform / calcMode=linear """
            return r_time
        if not {"pace", "time", "smooth"} & set(self.layer.tags) or not self.gif.options.all_pace:
            return vary

        if r_begin or r_end:
            def vary(r_time, vary=vary):
                """ shift and scale """
                if r_time < r_begin:
                    return 0.0
                elif r_time > r_end:
                    return 1.0
                else:
                    return (r_time - r_begin) / r_end

        if attr("repeatCount"):
            # meh: ignorantly zigzags the repeat into our .delay time slice
            def vary(r_time, count=int(attr("repeatCount")), vary=vary):
                """ amplify by repeatCount, modulo into 0.0 … 1.0 interval """
                return (count * vary(r_time)) % 1.0

        if attr("calcMode") == "discrete":
            def vary(r_time):
                """ just snaps from one to the other extreme; nobody will ever use it """
                return 0.0 if r_time < 0.5 else 1.0

        if attr("calcMode") == "paced" or smooth and not attr("calcMode"):
            def vary(r_time, vary=vary):
                """ dense sigmoid (quick approx via KmPlot) """
                return vary(10 / (10 + math.exp(-(9 * r_time - 7))))

        if attr("calcMode") == "spline" and attr("keyTimes"):  # (ignoring the actual keySplines= of course)
            def vary(r_time, steps=strargs(attr("keyTimes", "0;1")), vary=vary):
                """ only linear [0, 0.33, 0.66, 1.0] here, values=[] would require interpretation in handlers """
                length = len(steps) - 1
                dist = 1.0 / length                   # 0.25 for len=4
                offs = int(r_time * length)           # r=0.30 → vary index [1]
                steps = steps[:] + [1.0, 1.0]
                val_diff = steps[offs+1] - steps[offs]   # steps difference 0.33-0.25
                off_diff = (r_time - dist * offs) / dist # offset [2]+0.1 to quantify value
                return vary(steps[offs] + val_diff * off_diff)

        def vary(r_time, vary=vary):
            """ guard for invalid params and calculation hiccups """
            try:
                return min(max(vary(r_time), 0.0), 1.0)
            except:
                return r_time

        return vary

    @staticmethod
    def seconds(param, default=0):
        """ extract N.NNs from strings """
        if not param:
            return default
        found = re.findall(r"\b(\d[.\d]*)s", param)
        if found:
            return float(found[0])
        return default

#raise Exception(sys.argv)
if __name__ == "__main__":
    GIFExport().run()
    sys.exit(0)