Initial Commit

This commit is contained in:
OmniLottie
2026-03-01 21:36:54 +08:00
commit a386c803e1
199 changed files with 42253 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import os
import subprocess
from . import objects, parsers, utils, exporters, nvector, importers
from .nvector import *
from .utils.color import Color
try:
from .version import __version__
except ImportError:
here = os.path.dirname(os.path.abspath(__file__))
pipe = subprocess.Popen(
['git', 'describe', '--abbrev=0', '--tags'],
cwd=here,
stderr=subprocess.DEVNULL,
stdout=subprocess.PIPE
)
out, err = pipe.communicate()
if pipe.returncode == 0:
__version__ = out.strip()[1:].decode("ascii") + "+git"
else:
vfn = os.path.join(os.path.dirname(os.path.dirname(here)), "version")
if os.path.exists(vfn):
with open(vfn) as vf:
__version__ = vf.read().strip() + "+src"
else:
__version__ = "unknown"
try:
version_tuple = tuple(map(int, __version__.split("+")[0].split("."))) if __version__ != "unknown" else (0, 0, 0)
except ValueError:
version_tuple = (0, 0, 0)
__version__ = "unknown"
__all__ = ["objects", "parsers", "utils", "exporters", "nvector", "NVector", "Point", "Color", "importers"]
+19
View File
@@ -0,0 +1,19 @@
from . import base, core, sif, svg, pretty_print
from .base import exporters
from .core import export_lottie, export_tgs, export_embedded_html
from .pretty_print import prettyprint, prettyprint_summary
from .sif import export_sif
from .svg import export_svg
__all__ = [
"base", "core", "sif", "svg", "pretty_print",
"exporters", "export_lottie", "export_tgs", "export_embedded_html",
"prettyprint", "prettyprint_summary", "export_sif", "export_svg",
]
try:
from . import cairo, gif
__all__ += ["cairo", "gif"]
except ImportError:
pass
+32
View File
@@ -0,0 +1,32 @@
from ..parsers.baseporter import Baseporter, Loader, ExtraOption, io_progress
class ExporterLoader(Loader):
def __init__(self):
super().__init__(__file__, __name__, "export")
@property
def exporters(self):
return self.items
def set_options(self, parser):
group = parser.add_argument_group("Generic output options")
group.add_argument(
"--pretty", "-p",
action="store_true",
help="Pretty print (for formats that support it)",
)
group.add_argument(
"--frame",
type=int,
default=0,
help="Frame to extract (for single-image formats)",
)
super().set_options(parser)
return group
exporters = ExporterLoader()
exporter = exporters.decorator
+27
View File
@@ -0,0 +1,27 @@
import cairosvg
import io
from .base import exporter
from .svg import export_svg
def _export_cairo(func, animation, fp, frame, dpi):
intermediate = io.StringIO()
export_svg(animation, intermediate, frame)
intermediate.seek(0)
func(file_obj=intermediate, write_to=fp, dpi=dpi)
@exporter("PNG", ["png"], [], {"frame"})
def export_png(animation, fp, frame=0, dpi=96):
_export_cairo(cairosvg.svg2png, animation, fp, frame, dpi)
@exporter("PDF", ["pdf"], [], {"frame"})
def export_pdf(animation, fp, frame=0, dpi=96):
_export_cairo(cairosvg.svg2pdf, animation, fp, frame, dpi)
@exporter("PostScript", ["ps"], [], {"frame"})
def export_ps(animation, fp, frame=0, dpi=96):
_export_cairo(cairosvg.svg2ps, animation, fp, frame, dpi)
+119
View File
@@ -0,0 +1,119 @@
import sys
import json
import gzip
import codecs
from .base import exporter
from ..utils.file import open_file
from ..parsers.baseporter import ExtraOption
from .tgs_validator import TgsValidator
@exporter("Lottie JSON", ["json"], [], {"pretty"}, "lottie")
def export_lottie(animation, file, pretty=False):
with open_file(file) as fp:
kw = {}
if pretty:
kw = dict(indent=4)
json.dump(animation.to_dict(), fp, **kw)
@exporter("Telegram Animated Sticker", ["tgs"], [
ExtraOption("no_sanitize", help="Disable Sticker fit", action="store_false", dest="sanitize"),
ExtraOption("no_validate", help="Disable feature validation", action="store_false", dest="validate"),
])
def export_tgs(animation, file, sanitize=False, validate=False):
if sanitize:
animation.tgs_sanitize()
with gzip.open(file, "wb") as gzfile:
lottie_dict = animation.to_dict()
lottie_dict["tgs"] = 1
json.dump(lottie_dict, codecs.getwriter('utf-8')(gzfile))
if validate:
validator = TgsValidator()
validator(animation)
validator.check_file_size(file)
if validator.errors:
sys.stdout.write("\n".join(map(str, validator.errors))+"\n")
class HtmlOutput:
def __init__(self, animation, file):
self.animation = animation
self.file = file
def style(self):
self.file.write("""
<style>
#bodymovin { width: %spx; height: %spx; margin: auto;
background-color: white;
background-size: 64px 64px;
background-image:
linear-gradient(to right, rgba(0, 0, 0, .3) 50%%, transparent 50%%),
linear-gradient(to bottom, rgba(0, 0, 0, .3) 50%%, transparent 50%%),
linear-gradient(to bottom, white 50%%, transparent 50%%),
linear-gradient(to right, transparent 50%%, rgba(0, 0, 0, .5) 50%%);
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.5.3/lottie.js"></script>
""" % (self.animation.width, self.animation.height))
def body_pre(self):
self.file.write("""
<div id="bodymovin"></div>
<script>
var animData = {
container: document.getElementById('bodymovin'),
renderer: 'svg',
loop: true,
autoplay: true,
""")
def body_embedded(self):
self.file.write("animationData: ")
export_lottie(self.animation, self.file, True)
def body_post(self):
self.file.write("""
};
var anim = bodymovin.loadAnimation(animData);
</script>""")
def html_begin(self):
self.file.write("""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
html, body { width: 100%; height: 100%; margin: 0; }
body { display: flex; }
</style>""")
self.style()
self.file.write("</head><body>")
def html_end(self):
self.file.write("</body></html>")
@exporter("Lottie HTML", ["html", "htm"])
def export_embedded_html(animation, file):
with open_file(file) as fp:
out = HtmlOutput(animation, fp)
out.html_begin()
out.body_pre()
out.body_embedded()
out.body_post()
out.html_end()
def export_linked_html(animation, file, path):
with open_file(file) as fp:
out = HtmlOutput(animation, fp)
out.html_begin()
out.body_pre()
file.write("path: %r" % path)
out.body_post()
out.html_end()
+89
View File
@@ -0,0 +1,89 @@
import json
import string
import zipfile
from .base import exporter
from ..parsers.baseporter import ExtraOption
from ..parsers.tgs import parse_tgs
from lottie import __version__
from ..objects import assets
@exporter("dotLottie Archive", ["lottie"], [
ExtraOption("id", help="ID of the animation", default=None),
ExtraOption("append", help="Append animation to existing archive", action="store_true"),
ExtraOption("revision", help="File revision", type=int, default=None),
ExtraOption("author", help="File author", default=None),
ExtraOption("speed", help="Playback speed", type=float, default=1),
ExtraOption("theme_color", help="Theme color", type=str, default="#ffffff"),
ExtraOption("no_loop", help="Disable Looping", action="store_false", dest="loop"),
ExtraOption("no_pack", help="Don't auto-pack images", action="store_false", dest="pack_images"),
], slug="dotlottie")
def export_dotlottie(animation, file, id=None, append=False, revision=None, author=None,
speed=1.0, theme_color="#ffffff", loop=True, pack_images=True):
files = {}
if append:
with zipfile.ZipFile(file, "r") as zf:
with zf.open("manifest.json") as manifest:
meta = json.load(manifest)
for name in zf.namelist():
if name != "manifest.json":
files[name] = zf.read(name)
else:
meta = {
"generator": "Python Lottie " + __version__,
"version": 1.0,
"revision": 1,
"author": "",
"animations": [],
"custom": {}
}
if revision is not None:
meta["revision"] = revision
if author is not None:
meta["author"] = author
if id is None:
if animation.name:
idok = string.ascii_letters + string.digits + "_-"
id = "".join(filter(lambda x: x in idok, animation.name.replace(" ", "_")))
if not id:
id = "animation_%s" % len(meta["animations"])
meta["animations"].append({
"id": id,
"speed": speed,
"themeColor": theme_color,
"loop": loop,
})
if pack_images and animation.assets:
animation = animation.clone()
image_no = 0
for asset in animation.assets:
if isinstance(asset, assets.Image):
ext, data = asset.image_data()
if not ext:
continue
pathname = "images/"
while True:
basename = "image_%s.%s" % (image_no, ext)
image_no += 1
if pathname+basename not in files:
break
files[pathname+basename] = data
asset.image_path = pathname
asset.image = basename
asset.is_embedded = False
files["manifest.json"] = json.dumps(meta)
files["animations/%s.json" % id] = json.dumps(animation.to_dict())
with zipfile.ZipFile(file, "w") as zf:
for name, data in files.items():
zf.writestr(name, data)
+133
View File
@@ -0,0 +1,133 @@
import io
from PIL import Image
from PIL import features
from .cairo import export_png
from .base import exporter, io_progress
from ..parsers.baseporter import ExtraOption
def _png_gif_prepare(image):
if image.mode not in ["RGBA", "RGBa"]:
image = image.convert("RGBA")
alpha = image.getchannel("A")
image = image.convert("RGB").convert('P', palette=Image.ADAPTIVE, colors=255)
mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0)
image.paste(255, mask=mask)
return image
def _log_frame(fmt, frame_no=None, end=None):
if frame_no is None:
io_progress().report_message("%s frame rendering completed" % (fmt))
else:
io_progress().report_progress("%s rendering frame" % fmt, frame_no, end)
@exporter("GIF", ["gif"], [
ExtraOption("skip_frames", type=int, default=1, help="Only renderer 1 out of these many frames"),
])
def export_gif(animation, fp, dpi=96, skip_frames=1):
"""
Gif export
Note that it's a bit slow.
"""
start = int(animation.in_point)
end = int(animation.out_point)
frames = []
for i in range(start, end+1, skip_frames):
_log_frame("GIF", i, end)
file = io.BytesIO()
export_png(animation, file, i, dpi)
file.seek(0)
frames.append(_png_gif_prepare(Image.open(file)))
_log_frame("GIF")
io_progress().report_message("GIF Writing to file...")
duration = int(round(1000 / animation.frame_rate * skip_frames / 10)) * 10
frames[0].save(
fp,
format='GIF',
append_images=frames[1:],
save_all=True,
duration=duration,
loop=0,
transparency=255,
disposal=2,
)
@exporter("WebP", ["webp"], [
ExtraOption("lossless", action="store_true", help="If present, use lossless compression"),
ExtraOption("quality", type=int, default=80,
help="Compression effort between 0 and 100\n" +
"for lossy 0 gives the smallest size\n" +
"for lossless 0 gives the largest file"),
ExtraOption("method", type=int, default=0, help="Quality/speed trade-off (0=fast, 6=slower-better)"),
ExtraOption("skip_frames", type=int, default=1, help="Only renderer 1 out of these many frames"),
])
def export_webp(animation, fp, dpi=96, lossless=False, quality=80, method=0, skip_frames=1):
"""
Export WebP
See https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#webp
"""
if not features.check("webp_anim"):
raise Exception("WebP animations not supported in this system")
start = int(animation.in_point)
end = int(animation.out_point)
frames = []
for i in range(start, end+1, skip_frames):
_log_frame("WebP", i, end)
file = io.BytesIO()
export_png(animation, file, i, dpi)
file.seek(0)
frames.append(Image.open(file))
_log_frame("WebP")
io_progress().report_message("WebP Writing to file...")
duration = int(round(1000 / animation.frame_rate * skip_frames))
frames[0].save(
fp,
format='WebP',
append_images=frames[1:],
save_all=True,
duration=duration,
loop=0,
background=(0, 0, 0, 0),
lossless=lossless,
quality=quality,
method=method
)
@exporter("TIFF", ["tiff"])
def export_tiff(animation, fp, dpi=96):
"""
Export TIFF
"""
start = int(animation.in_point)
end = int(animation.out_point)
frames = []
for i in range(start, end+1):
_log_frame("TIFF", i, end)
file = io.BytesIO()
export_png(animation, file, i, dpi)
file.seek(0)
frames.append(Image.open(file))
_log_frame("TIFF")
io_progress().report_message("TIFF Writing to file...")
duration = int(round(1000 / animation.frame_rate))
frames[0].save(
fp,
format='TIFF',
append_images=frames[1:],
save_all=True,
duration=duration,
loop=0,
dpi=(dpi, dpi),
)
+71
View File
@@ -0,0 +1,71 @@
import sys
from ..objects.base import LottieObject, LottieBase
from ..objects.properties import MultiDimensional, Value, ShapeProperty
from ..objects.layers import Layer
def _prettyprint_scalar(lottie_object, out=sys.stdout):
if isinstance(lottie_object, float) and lottie_object == round(lottie_object):
lottie_object = int(lottie_object)
return str(lottie_object)
def prettyprint(lottie_object, out=sys.stdout, indent=" ", _i=""):
if isinstance(lottie_object, LottieObject):
out.write(lottie_object.__class__.__name__)
out.write('\n')
_i += indent
maxk = max(map(lambda x: len(x.name), lottie_object._props))
for k in lottie_object._props:
out.write(_i)
out.write(k.name.ljust(maxk))
out.write(' : ')
prettyprint(k.get(lottie_object), out, indent, _i)
elif isinstance(lottie_object, (list, tuple)):
if not lottie_object or (not isinstance(lottie_object[0], LottieBase) and len(lottie_object) < 16):
out.write("[")
out.write(", ".join(map(_prettyprint_scalar, lottie_object)))
out.write("]\n")
else:
out.write("[\n")
for k in lottie_object:
out.write(_i + indent)
prettyprint(k, out, indent, _i + indent)
out.write(_i)
out.write(']\n')
else:
out.write(_prettyprint_scalar(lottie_object, out))
out.write('\n')
def _prettyprint_summary_printable(obj):
if isinstance(obj, LottieObject):
return not isinstance(obj, (MultiDimensional, Value, ShapeProperty))
return obj and isinstance(obj, (list, tuple)) and isinstance(obj[0], LottieObject)
def prettyprint_summary(lottie_object, out=sys.stdout, indent=" ", _i=""):
if isinstance(lottie_object, LottieObject):
out.write(lottie_object.__class__.__name__)
name = getattr(lottie_object, "name", None)
if name:
out.write(" %r" % name)
if isinstance(lottie_object, Layer):
out.write(" %s -> %s" % (lottie_object.index, lottie_object.parent_index))
out.write('\n')
_i += indent
for k in lottie_object._props:
val = k.get(lottie_object)
if _prettyprint_summary_printable(val):
out.write(_i)
out.write(k.name)
out.write(' : ')
prettyprint_summary(val, out, indent, _i)
elif _prettyprint_summary_printable(lottie_object):
out.write("[\n")
for k in lottie_object:
out.write(_i + indent)
prettyprint_summary(k, out, indent, _i + indent)
out.write(_i)
out.write(']\n')
+11
View File
@@ -0,0 +1,11 @@
from .base import exporter
from ..parsers.sif.builder import to_sif
from ..utils.file import open_file
@exporter("Synfig", ["sif"], [], {"pretty"})
def export_sif(animation, file, pretty=True):
with open_file(file) as fp:
dom = to_sif(animation).to_xml()
dom.writexml(fp, "", " " if pretty else "", "\n" if pretty else "")
+22
View File
@@ -0,0 +1,22 @@
from xml.dom import minidom
from xml.etree import ElementTree
from .base import exporter
from ..parsers.svg.builder import to_svg
from ..utils.file import open_file
def _print_ugly_xml(dom, file):
return dom.write(file, "utf-8", True)
def _print_pretty_xml(dom, file):
with open_file(file) as fp:
xmlstr = minidom.parseString(ElementTree.tostring(dom.getroot())).toprettyxml(indent=" ")
fp.write(xmlstr)
@exporter("SVG", ["svg"], [], {"pretty", "frame"})
def export_svg(animation, file, frame=0, pretty=True):
_print_xml = _print_pretty_xml if pretty else _print_ugly_xml
_print_xml(to_svg(animation, frame), file)
+198
View File
@@ -0,0 +1,198 @@
import os
import enum
import json
import inspect
from ..parsers.tgs import parse_tgs
from ..objects.base import ObjectVisitor
from ..objects.animation import Animation
from ..objects import layers
from ..objects import shapes
from ..objects import helpers
class Severity(enum.Enum):
Note = enum.auto()
Warning = enum.auto()
Error = enum.auto()
class TgsError:
def __init__(self, message, target, severity=Severity.Warning):
self.message = message
self.target = target
self.severity = severity
def target_id(self):
if isinstance(self.target, str):
return self.target
if getattr(self.target, "name", ""):
return self.target.name
return self.target.__class__.__name__
def __str__(self):
return "%s: on %s: %s" % (
self.severity.name,
self.target_id(),
self.message
)
class TgsValidator(ObjectVisitor):
def __init__(self, severity=Severity.Note):
self.errors = []
self.severity = severity
def _check(self, expr, message, target, severity=Severity.Warning):
if severity.value >= self.severity.value and not expr:
self.errors.append(TgsError(message, target, severity))
def check_file_size(self, filename):
return self.check_size(os.path.getsize(filename))
def check_size(self, bytes, filename="file"):
size_k = bytes / 1024
self._check(
size_k <= 64,
"Invalid size (%.1fk), should be less than 64k" % size_k,
filename,
Severity.Error
)
def check_file(self, filename):
self.check_file_size(filename)
try:
self(parse_tgs(filename))
except json.decoder.JSONDecodeError as e:
self._check(
False,
"Invalid JSON: %s" % e,
filename,
Severity.Error
)
def visit(self, object):
for cls in inspect.getmro(object.__class__):
callback = "_visit_%s" % cls.__name__.lower()
if hasattr(self, callback):
getattr(self, callback)(object)
def _visit_animation(self, o: Animation):
self._check(
o.frame_rate in {30, 60},
"Invalid framerate %s, should be 30 or 60" % o.frame_rate,
o,
Severity.Error
)
self._check(
o.width == 512,
"Invalid width %s, should be 512" % o.width,
o,
Severity.Error
)
self._check(
o.height == 512,
"Invalid height %s, should be 512" % o.height,
o,
Severity.Error
)
self._check(
(o.out_point-o.in_point) <= 180,
"Too many frames (%s), should be less than 180" % (o.out_point-o.in_point),
o,
Severity.Error
)
def _visit_layer(self, o: layers.Layer):
self._check(
not o.has_masks and not o.masks,
"Masks are not officially supported",
o,
Severity.Note
)
self._check(
not o.effects,
"Effects are not supported",
o,
Severity.Warning
)
self._check(
not o.threedimensional,
"3D layers are not supported",
o,
Severity.Warning
)
self._check(
not isinstance(o, layers.TextLayer),
"Text layers are not supported",
o,
Severity.Warning
)
self._check(
not isinstance(o, layers.ImageLayer),
"Image layers are not supported",
o,
Severity.Warning
)
self._check(
not o.auto_orient,
"Auto-orient layers are not supported",
o,
Severity.Warning
)
self._check(
o.matte_mode in {None, layers.MatteMode.Normal},
"Mattes are not supported",
o,
Severity.Warning
)
def _visit_precomplayer(self, o: layers.PreCompLayer):
self._check(
o.time_remapping is None,
"Time remapping is not supported",
o,
Severity.Warning
)
def _visit_merge(self, o: shapes.Merge):
self._check(
False,
"Merge paths are not supported",
o,
Severity.Warning
)
def _visit_transform(self, o: helpers.Transform):
self._check(
o.skew is None or (
not o.skew.animated and o.skew.value == 0
),
"Skew transforms are not supported",
o,
Severity.Warning
)
def _visit_gradientstroke(self, o: shapes.GradientStroke):
self._check(
False,
"Gradient strokes are not officially supported",
o,
Severity.Note
)
def _visit_star(self, o: shapes.Star):
self._check(
False,
"Star Shapes are not officially supported",
o,
Severity.Note
)
def _visit_repeater(self, o: shapes.Repeater):
self._check(
False,
"Repeaters are not officially supported",
o,
Severity.Note
)
+43
View File
@@ -0,0 +1,43 @@
import io
import os
import cv2
import numpy
from PIL import Image
from .cairo import export_png
from .gif import _log_frame
from .base import exporter
from ..parsers.baseporter import ExtraOption
## @see http://www.fourcc.org/codecs.php
formats4cc = {
"avi": cv2.VideoWriter_fourcc(*"XVID"),
"mp4": cv2.VideoWriter_fourcc(*'MP4V'),
#"mp4": cv2.VideoWriter_fourcc(*'X264'),
"webm": cv2.VideoWriter_fourcc(*'VP80'),
}
@exporter("Video", list(formats4cc.keys()), [
ExtraOption("format", default=None, help="Specific video format", choices=list(formats4cc.keys())),
], [], "video")
def export_video(animation, fp, format=None):
start = int(animation.in_point)
end = int(animation.out_point)
if format is None:
fn = fp if isinstance(fp, str) else fp.name
format = os.path.splitext(fn)[1][1:]
fmt = formats4cc[format]
video = cv2.VideoWriter(fp, fmt, animation.frame_rate, (animation.width, animation.height))
for i in range(start, end+1):
_log_frame(format, i, end)
file = io.BytesIO()
export_png(animation, file, i)
file.seek(0)
video.write(cv2.cvtColor(numpy.array(Image.open(file)), cv2.COLOR_RGB2BGR))
_log_frame(format)
video.release()
+13
View File
@@ -0,0 +1,13 @@
from . import base, core, sif, svg
from .base import importers
__all__ = [
"base", "core", "sif", "svg",
"importers",
]
try:
from . import raster
__all__ += ["raster"]
except ImportError:
pass
+21
View File
@@ -0,0 +1,21 @@
from ..parsers.baseporter import Baseporter, Loader
class ImporterLoader(Loader):
def __init__(self):
super().__init__(__file__, __name__, "import")
@property
def importers(self):
return self.items
def set_options(self, parser):
group = parser.add_argument_group("Generic input options")
super().set_options(parser)
return group
importers = ImporterLoader()
importer = importers.decorator
+7
View File
@@ -0,0 +1,7 @@
from .base import importer
from ..parsers.tgs import parse_tgs
@importer("Lottie JSON / Telegram Sticker", ["json", "tgs"], slug="lottie")
def import_tgs(file, *a, **kw):
return parse_tgs(file, *a, **kw)
+32
View File
@@ -0,0 +1,32 @@
import json
import zipfile
from .base import importer
from ..parsers.baseporter import ExtraOption
from ..parsers.tgs import parse_tgs
from ..objects import Animation, assets
@importer("dotLottie Archive", ["lottie"], [
ExtraOption("id", help="ID of the animation to extract", default=None)
], slug="dotlottie")
def import_dotlottie(file, id=None):
with zipfile.ZipFile(file) as zf:
with zf.open("manifest.json") as manifest:
meta = json.load(manifest)
if id is None:
id = meta["animations"][0]["id"]
info = zf.getinfo("animations/%s.json" % id)
with zf.open(info) as animfile:
an = Animation.load(json.load(animfile))
if an.assets:
for asset in an.assets:
if isinstance(asset, assets.Image) and not asset.is_embedded:
fname = asset.image_path + asset.image
if fname in zf.namelist():
with zf.open(fname) as imgfile:
asset.load(imgfile)
return an
+58
View File
@@ -0,0 +1,58 @@
import zipfile
import warnings
from xml.etree import ElementTree
from .base import importer
from ..parsers.svg.importer import SvgParser
from .. import objects
ns = "{%s}" % "http://www.calligra.org/DTD/krita"
def _ns(string):
return string.format(ns=ns)
def _import_layers(zf, animation, xml_parent, svg_parser, parent):
for xml_layer in xml_parent.findall(_ns("./{ns}layers/{ns}layer")):
nodetype = xml_layer.attrib["nodetype"]
if nodetype == "grouplayer":
layer = animation.add_layer(objects.NullLayer())
_import_layers(zf, animation, xml_layer, svg_parser, layer)
elif nodetype == "shapelayer":
filename = "%s/layers/%s.shapelayer/content.svg" % (animation.name, xml_layer.attrib["filename"])
with zf.open(filename) as svg_tree:
layer = svg_parser.etree_to_layer(animation, ElementTree.parse(svg_tree))
else:
warnings.warn("Unsupported krita layer %s" % nodetype)
continue
layer.name = xml_layer.attrib["name"]
if xml_layer.attrib["visible"] == 0:
layer.transform.opacity.value = 0
layer.parent = parent
@importer("Krita", ["kra"])
def import_krita(file):
with zipfile.ZipFile(file) as zf:
with zf.open("maindoc.xml") as main:
main_xml = ElementTree.parse(main)
image = main_xml.find(_ns("./{ns}IMAGE"))
fps = float(main_xml.find(_ns("./{ns}IMAGE/{ns}animation/{ns}framerate")).attrib["value"])
framerange = main_xml.find(_ns("./{ns}IMAGE/{ns}animation/{ns}range")).attrib
animation = objects.Animation(int(framerange["to"]), fps)
animation.in_point = int(framerange["from"])
animation.width = int(image.attrib["width"])
animation.height = int(image.attrib["height"])
animation.name = image.attrib["name"]
parser = SvgParser()
parser.dpi = int(image.attrib["x-res"])
_import_layers(zf, animation, image, parser, None)
return animation
+72
View File
@@ -0,0 +1,72 @@
from .base import importer
from ..parsers.baseporter import ExtraOption
from ..parsers.pixel import (
pixel_to_animation_paths, pixel_to_animation,
raster_to_embedded_assets, raster_to_linked_assets
)
from ..parsers.svg.importer import parse_color
try:
from ..parsers.raster import raster_to_animation
raster = True
except ImportError:
raster = False
@importer("Raster image", ["bmp", "png", "gif", "webp", "tiff"], [
ExtraOption("n_colors", type=int, default=1, help="Number of colors to quantize"),
ExtraOption("palette", type=parse_color, default=[], nargs="+", help="Custom palette"),
ExtraOption(
"mode",
default="embed",
choices=["external", "embed", "pixel", "polygon"] + (["trace"] if raster else []),
help="Vectorization mode:\n" +
" * external : load images as linked assets\n" +
" * embed : load images as embedded assets\n" +
" * pixel : Vectorize the image into rectangles\n" +
" * polygon : Vectorize the image into polygonal shapes\n" +
" Looks the same as pixel, but a single shape per color\n" +
" * trace : (if available) Use potrace to vectorize\n"
),
ExtraOption("frame_delay", type=int, default=4, help="Number of frames to skip between images"),
ExtraOption("framerate", type=int, default=60, help="Frames per second"),
ExtraOption("frame_files", nargs="+", default=[], help="Additional frames to import"),
ExtraOption(
"color_mode",
default="nearest",
choices=["nearest", "exact"],
help="How to quantize colors.\n" +
" * nearest will map each color to the most similar in the palette\n" +
" * exact will only match exact colors"
),
ExtraOption(
"embed_format",
default=None,
help="Format to store images internally when using `embed` mode"
),
])
def import_raster(filenames, n_colors, palette, mode, frame_delay=1,
framerate=60, frame_files=[], color_mode="nearest", embed_format=None):
if not isinstance(filenames, list):
filenames = [filenames]
filenames = filenames + frame_files
if mode == "embed":
return raster_to_embedded_assets(filenames, frame_delay, framerate, embed_format)
elif mode == "external":
return raster_to_linked_assets(filenames, frame_delay, framerate)
elif mode == "trace":
from ..parsers.raster import QuanzationMode
# TODO QuanzationMode for raster
cm = QuanzationMode.Nearest if color_mode == "nearest" else QuanzationMode.Exact
return raster_to_animation(
filenames, n_colors, frame_delay,
framerate=framerate,
palette=palette,
mode=cm
)
elif mode == "polygon":
return pixel_to_animation_paths(filenames, frame_delay, framerate)
else:
return pixel_to_animation(filenames, frame_delay, framerate)
+16
View File
@@ -0,0 +1,16 @@
import json
import tempfile
import subprocess
from .base import importer
from ..objects import Animation
@importer("Python script", ["py"])
def import_python_script(file, *a, **kw):
out = subprocess.check_output(["python", file, "--version"])
if b"python-lottie script" not in out:
raise Exception("Not a valid script")
data = subprocess.check_output(["python", file, "--path", "", "--name", "-", "--format", "json"])
return Animation.load(json.loads(data))
+7
View File
@@ -0,0 +1,7 @@
from .base import importer
from ..parsers.sif import parse_sif_file
@importer("Synfig", ["sif", "sifz"])
def import_sif(file, *a, **kw):
return parse_sif_file(file, *a, **kw)
+16
View File
@@ -0,0 +1,16 @@
from .base import importer
from ..parsers.baseporter import ExtraOption
from ..parsers.svg import parse_svg_file
from ..parsers.tgs import open_maybe_gzipped
@importer("SVG", ["svg", "svgz"], [
ExtraOption(
"layer_frames", type=int, default=0,
help="If greater than 0, treats every layer in the SVG as a different animation frame,\n"
"greater values increase the time each frames lasts for."),
ExtraOption("n_frames", type=int, default=60),
ExtraOption("framerate", type=int, default=60),
])
def import_svg(file, *a, **kw):
return open_maybe_gzipped(file, lambda svgfile: parse_svg_file(svgfile, *a, **kw))
+148
View File
@@ -0,0 +1,148 @@
import operator
import math
def vop(op, a, b):
return list(map(op, a, b))
class NVector():
def __init__(self, *components):
self.components = list(components)
def __str__(self):
return str(self.components)
def __repr__(self):
return "<NVector %s>" % self
def __len__(self):
return len(self.components)
def to_list(self):
return list(self.components)
def __add__(self, other):
return type(self)(*vop(operator.add, self.components, other.components))
def __sub__(self, other):
return type(self)(*vop(operator.sub, self.components, other.components))
def __mul__(self, scalar):
if isinstance(scalar, NVector):
return type(self)(*vop(operator.mul, self.components, scalar.components))
return type(self)(*(c * scalar for c in self.components))
def __truediv__(self, scalar):
return type(self)(*(c / scalar for c in self.components))
def __iadd__(self, other):
self.components = vop(operator.add, self.components, other.components)
return self
def __isub__(self, other):
self.components = vop(operator.sub, self.components, other.components)
return self
def __imul__(self, scalar):
if isinstance(scalar, NVector):
self.components = vop(operator.mul, self.components, scalar.components)
else:
self.components = [c * scalar for c in self.components]
return self
def __itruediv__(self, scalar):
self.components = [c / scalar for c in self.components]
return self
def __neg__(self):
return type(self)(*(-c for c in self.components))
def __getitem__(self, key):
if isinstance(key, slice):
return NVector(*self.components[key])
return self.components[key]
def __setitem__(self, key, value):
self.components[key] = value
def __eq__(self, other):
return self.components == other.components
def __abs__(self):
return type(self)(*(abs(c) for c in self.components))
@property
def length(self):
return math.sqrt(sum(map(lambda x: x**2, self.components)))
def dot(self, other):
return sum(map(operator.mul, self.components, other.components))
def clone(self):
return NVector(*self.components)
def lerp(self, other, t):
return self * (1-t) + other * t
@property
def x(self):
return self.components[0]
@x.setter
def x(self, v):
self.components[0] = v
@property
def y(self):
return self.components[1]
@y.setter
def y(self, v):
self.components[1] = v
@property
def z(self):
return self.components[2]
@z.setter
def z(self, v):
self.components[2] = v
def element_scaled(self, other):
return type(self)(*vop(operator.mul, self.components, other.components))
def cross(self, other):
"""
@pre len(self) == len(other) == 3
"""
a = self
b = other
return type(self)(
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
)
@property
def polar_angle(self):
"""
@pre len(self) == 2
"""
return math.atan2(self.y, self.x)
def Point(x, y):
return NVector(x, y)
def Size(x, y):
return NVector(x, y)
def Point3D(x, y, z):
return NVector(x, y, z)
def PolarVector(length, theta):
return NVector(length * math.cos(theta), length * math.sin(theta))
+23
View File
@@ -0,0 +1,23 @@
"""!
Package with all the Lottie Python bindings
"""
from . import (
animation, base, effects, enums, helpers, layers, shapes, assets, easing,
text, bezier, composition
)
from .animation import Animation
from .layers import *
from .shapes import *
from .assets import Precomp
from .bezier import Bezier
from .composition import Composition
__all__ = [
"animation", "base", "effects", "enums", "helpers", "layers", "shapes", "assets",
"easing", "text", "bezier",
"Animation",
"NullLayer", "TextLayer", "ShapeLayer", "ImageLayer", "PreCompLayer", "SolidColorLayer",
"Rect", "Fill", "Trim", "Repeater", "GradientFill", "Stroke", "RoundedCorners", "Path",
"TransformShape", "Group", "Star", "Ellipse", "Merge", "GradientStroke",
"Bezier", "Precomp", "Composition",
]
+123
View File
@@ -0,0 +1,123 @@
from .base import LottieObject, LottieProp, PseudoBool, Index
from .layers import Layer
from .assets import Asset, Chars, Precomp
from .text import FontList
from .composition import Composition
##\defgroup Lottie Lottie
#
# Objects of the lottie file structure.
## \defgroup LottieCheck Lottie (to check)
#
# Lottie objects that have not been tested
## @ingroup Lottie
class Animation(Composition):
"""!
Top level object, describing the animation
@see http://docs.aenhancers.com/items/compitem/
"""
_props = [
LottieProp("version", "v", str, False),
LottieProp("frame_rate", "fr", float, False),
LottieProp("in_point", "ip", float, False),
LottieProp("out_point", "op", float, False),
LottieProp("width", "w", int, False),
LottieProp("height", "h", int, False),
LottieProp("name", "nm", str, False),
LottieProp("threedimensional", "ddd", PseudoBool, False),
LottieProp("assets", "assets", Asset, True),
#LottieProp("comps", "comps", Animation, True),
LottieProp("fonts", "fonts", FontList),
LottieProp("chars", "chars", Chars, True),
#LottieProp("markers", "markers", Marker, True),
#LottieProp("motion_blur", "mb", MotionBlur, False),
]
_version = "5.5.2"
def __init__(self, n_frames=60, framerate=60):
super().__init__()
## The time when the composition work area begins, in frames.
self.in_point = 0
## The time when the composition work area ends.
## Sets the final Frame of the animation
self.out_point = n_frames
## Frames per second
self.frame_rate = framerate
## Composition Width
self.width = 512
## Composition has 3-D layers
self.threedimensional = False
## Composition Height
self.height = 512
## Bodymovin Version
self.version = self._version
## Composition name
self.name = None
## source items that can be used in multiple places. Comps and Images for now.
self.assets = [] # Image, Precomp
## source chars for text layers
self.chars = None
## Available fonts
self.fonts = None
def precomp(self, name):
for ass in self.assets:
if isinstance(ass, Precomp) and ass.id == name:
return ass
return None
def _on_prepare_layer(self, layer):
if layer.in_point is None:
layer.in_point = self.in_point
if layer.out_point is None:
layer.out_point = self.out_point
def tgs_sanitize(self):
"""!
Cleans up some things to ensure it works as a telegram sticker
"""
if self.width != 512 or self.height != 512:
scale = min(512/self.width, 512/self.height)
self.width = self.height = 512
for layer in self.layers:
if layer.parent_index:
continue
if layer.transform.scale.animated:
for kf in layer.transform.scale.keyframes:
if kf.start is not None:
kf.start *= scale
if kf.end is not None:
kf.end *= scale
else:
layer.transform.scale.value *= scale
if layer.transform.position.animated:
for kf in layer.transform.position.keyframes:
if kf.start is not None:
kf.start *= scale
if kf.end is not None:
kf.end *= scale
else:
layer.transform.position.value *= scale
if self.frame_rate < 45:
self.frame_rate = 30
else:
self.frame_rate = 60
def _fixup(self):
super()._fixup()
if self.assets:
for ass in self.assets:
if isinstance(ass, Precomp):
ass.animation = self
ass._fixup()
def __str__(self):
return self.name or super().__str__()
+209
View File
@@ -0,0 +1,209 @@
import os
import re
import base64
import mimetypes
from io import BytesIO
from .base import LottieObject, LottieProp, PseudoBool, Index
from .layers import Layer
from .shapes import ShapeElement
from .composition import Composition
## @ingroup Lottie
class Asset(LottieObject):
@classmethod
def _load_get_class(cls, lottiedict):
if "p" in lottiedict or "u" in lottiedict:
return Image
if "layers" in lottiedict:
return Precomp
## @ingroup Lottie
class Image(Asset):
"""!
External image
@see http://docs.aenhancers.com/sources/filesource/
"""
_props = [
LottieProp("height", "h", float, False),
LottieProp("width", "w", float, False),
LottieProp("id", "id", str, False),
LottieProp("image", "p", str, False),
LottieProp("image_path", "u", str, False),
LottieProp("is_embedded", "e", PseudoBool, False),
]
@staticmethod
def guess_mime(file):
if isinstance(file, str):
filename = file
elif hasattr(file, "name"):
filename = file.name
else:
return "application/octet-stream"
return mimetypes.guess_type(filename)
def __init__(self, id=""):
## Image Height
self.height = 0
## Image Width
self.width = 0
## Image ID
self.id = id
## Image name
self.image = ""
## Image path
self.image_path = ""
## Image data is stored as a data: url
self.is_embedded = False
def load(self, file, format=None):
"""!
@param file Filename, file object, or PIL.Image.Image to load
@param format Format to store the image data as
"""
from PIL import Image
if not isinstance(file, Image.Image):
image = Image.open(file)
else:
image = file
self._id_from_file(file)
self.image_path = ""
if format is None:
format = (image.format or "png").lower()
self.width, self.height = image.size
output = BytesIO()
image.save(output, format=format)
self.image = "data:image/%s;base64,%s" % (
format,
base64.b64encode(output.getvalue()).decode("ascii")
)
self.is_embedded = True
return self
def _id_from_file(self, file):
if not self.id:
if isinstance(file, str):
self.id = os.path.basename(file)
elif hasattr(file, "name"):
self.id = os.path.basename(file.name)
elif hasattr(file, "filename"):
self.id = os.path.basename(file.filename)
else:
self.id = "image_%s" % id(self)
@classmethod
def embedded(cls, image, format=None):
"""!
Create an object from an image file
"""
lottie_image = cls()
return lottie_image.load(image, format)
@classmethod
def linked(cls, filename):
from PIL import Image
image = Image.open(filename)
lottie_image = cls()
lottie_image._id_from_file(filename)
lottie_image.image_path, lottie_image.image = os.path.split(filename)
lottie_image.image_path += "/"
lottie_image.width = image.width
lottie_image.height = image.height
return lottie_image
def image_data(self):
"""
Returns a tuple (format, data) with the contents of the image
`format` is a string like "png", and `data` is just raw binary data.
If it's impossible to fetch this info, returns (None, None)
"""
if self.is_embedded:
m = re.match("data:[^/]+/([^;,]+);base64,(.*)", self.image)
if m:
return m.group(1), base64.b64decode(m.group(2))
return None, None
path = self.image_path + self.image
if os.path.isfile(path):
with open(path, "rb") as imgfile:
return os.path.splitext(path)[1][1:], imgfile.read()
return None, None
## @ingroup Lottie
class CharacterData(LottieObject):
"""!
Character shapes
"""
_props = [
LottieProp("shapes", "shapes", ShapeElement, True),
]
def __init__(self):
self.shapes = []
## @ingroup Lottie
class Chars(LottieObject):
"""!
Defines character shapes to avoid loading system fonts
"""
_props = [
LottieProp("character", "ch", str, False),
LottieProp("font_family", "fFamily", str, False),
LottieProp("font_size", "size", float, False),
LottieProp("font_style", "style", str, False),
LottieProp("width", "w", float, False),
LottieProp("data", "data", CharacterData, False),
]
def __init__(self):
## Character Value
self.character = ""
## Character Font Family
self.font_family = ""
## Character Font Size
self.font_size = 0
## Character Font Style
self.font_style = "" # Regular
## Character Width
self.width = 0
## Character Data
self.data = CharacterData()
@property
def shapes(self):
return self.data.shapes
## @ingroup Lottie
class Precomp(Asset, Composition):
_props = [
LottieProp("id", "id", str, False),
]
def __init__(self, id="", animation=None):
super().__init__()
## Precomp ID
self.id = id
self.animation = animation
if animation:
self.animation.assets.append(self)
def _on_prepare_layer(self, layer):
if self.animation:
self.animation.prepare_layer(layer)
def set_timing(self, outpoint, inpoint=0, override=True):
for layer in self.layers:
if override or layer.in_point is None:
layer.in_point = inpoint
if override or layer.out_point is None:
layer.out_point = outpoint
+378
View File
@@ -0,0 +1,378 @@
import enum
import inspect
import importlib
from .nvector import NVector
from .color import Color
class LottieBase:
"""!
Base class for Lottie JSON objects bindings
"""
def to_dict(self):
"""!
Serializes into a JSON object fit for the Lottie format
"""
raise NotImplementedError
@classmethod
def load(cls, lottiedict):
"""!
Loads from a JSON object
@returns An instance of the class
"""
raise NotImplementedError
def clone(self):
"""!
Returns a copy of the object
"""
raise NotImplementedError
class EnumMeta(enum.EnumMeta):
"""!
Hack to counter-hack the hack in enum meta
"""
def __new__(cls, name, bases, classdict):
classdict["__reduce_ex__"] = lambda *a, **kw: None # pragma: no cover
return super().__new__(cls, name, bases, classdict)
class LottieEnum(LottieBase, enum.Enum, metaclass=EnumMeta):
"""!
Base class for enum-like types in the Lottie JSON structure
"""
def to_dict(self):
return self.value
@classmethod
def load(cls, lottieint):
return cls(lottieint)
def clone(self):
return self
class PseudoList:
"""!
List tag for some weird values in the Lottie JSON
"""
pass
class LottieValueConverter:
"""!
Factory for property types that require special conversions
"""
def __init__(self, py, lottie, name=None):
self.py = py
self.lottie = lottie
self.name = name or "%s but displayed as %s" % (self.py.__name__, self.lottie.__name__)
def py_to_lottie(self, val):
return self.lottie(val)
def lottie_to_py(self, val):
return self.py(val)
@property
def __name__(self):
return self.name
## For values in Lottie that are bools but ints in the JSON
PseudoBool = LottieValueConverter(bool, int, "0-1 int")
class LottieProp:
"""!
Lottie <-> Python property mapper
"""
def __init__(self, name, lottie, type=float, list=False, cond=None):
## Name of the Python property
self.name = name
## Name of the Lottie JSON property
self.lottie = lottie
## Type of the property
## @see LottieValueConverter, PseudoBool
self.type = type
## Whether the property is a list of self.type
## @see PseudoList
self.list = list
## Condition on when the property is loaded from the Lottie JSON
self.cond = cond
def get(self, obj):
"""!
Returns the value of the property from a Python object
"""
return getattr(obj, self.name)
def set(self, obj, value):
"""!
Sets the value of the property from a Python object
"""
if isinstance(getattr(obj.__class__, self.name, None), property):
return
return setattr(obj, self.name, value)
def load_from_parent(self, lottiedict):
"""!
Returns the value for this property from a JSON dict representing the parent object
@returns The loaded value or @c None if the property is not in @p lottiedict
"""
if self.lottie in lottiedict:
return self.load(lottiedict[self.lottie])
return None
def load_into(self, lottiedict, obj):
"""!
Loads from a Lottie dict into an object
"""
if self.cond and not self.cond(lottiedict):
return
self.set(obj, self.load_from_parent(lottiedict))
def load(self, lottieval):
"""!
Loads the property from a JSON value
@returns the Python equivalent of the JSON value
"""
if self.list is PseudoList and isinstance(lottieval, list):
return self._load_scalar(lottieval[0])
#return [
#self._load_scalar(it)
#for it in lottieval
#]
elif self.list is True:
return list(filter(lambda x: x is not None, (
self._load_scalar(it)
for it in lottieval
)))
return self._load_scalar(lottieval)
def _load_scalar(self, lottieval):
if lottieval is None:
return None
if inspect.isclass(self.type) and issubclass(self.type, LottieBase):
return self.type.load(lottieval)
elif isinstance(self.type, type) and isinstance(lottieval, self.type):
return lottieval
elif isinstance(self.type, LottieValueConverter):
return self.type.lottie_to_py(lottieval)
elif self.type is NVector:
return NVector(*lottieval)
elif self.type is Color:
return Color(*lottieval)
if isinstance(lottieval, list) and lottieval:
lottieval = lottieval[0]
return self.type(lottieval)
def to_dict(self, obj):
"""!
Converts the value of the property as from @p obj into a JSON value
@param obj LottieObject with this property
"""
val = self._basic_to_dict(self.get(obj))
if self.list is PseudoList:
if not isinstance(obj, list):
return [val]
elif isinstance(self.type, LottieValueConverter):
val = self._basic_to_dict(self.type.py_to_lottie(val))
return val
def _basic_to_dict(self, v):
if isinstance(v, LottieBase):
return v.to_dict()
elif isinstance(v, NVector):
return list(map(self._basic_to_dict, v.components))
elif isinstance(v, list):
return list(map(self._basic_to_dict, v))
elif isinstance(v, (int, str, bool)):
return v
elif isinstance(v, float):
if v % 1 == 0:
return int(v)
return v #round(v, 3)
else:
raise Exception("Unknown value %r" % v)
def __repr__(self):
return "<LottieProp %s:%s>" % (self.name, self.lottie)
def clone_value(self, value):
if isinstance(value, list):
return [self.clone_value(v) for v in value]
if isinstance(value, (LottieBase, NVector)):
return value.clone()
if isinstance(value, (int, float, bool, str)) or value is None:
return value
raise Exception("Could not convert %r" % value)
class LottieObjectMeta(type):
def __new__(cls, name, bases, attr):
props = []
for base in bases:
if type(base) == cls:
props += base._props
attr["_props"] = props + attr.get("_props", [])
return super().__new__(cls, name, bases, attr)
class LottieObject(LottieBase, metaclass=LottieObjectMeta):
"""!
@brief Base class for mapping Python classes into Lottie JSON objects
"""
def to_dict(self):
return {
prop.lottie: prop.to_dict(self)
for prop in self._props
if prop.get(self) is not None
}
@classmethod
def load(cls, lottiedict):
if "__pyclass" in lottiedict:
return CustomObject.load(lottiedict)
if not lottiedict:
return None
cls = cls._load_get_class(lottiedict)
obj = cls()
for prop in cls._props:
prop.load_into(lottiedict, obj)
return obj
@classmethod
def _load_get_class(cls, lottiedict):
return cls
def find(self, search, propname="name"):
"""!
@param search The value of the property to search
@param propname The name of the property used to search
@brief Recursively searches for child objects with a matching property
"""
if getattr(self, propname, None) == search:
return self
for prop in self._props:
v = prop.get(self)
if isinstance(v, LottieObject):
found = v.find(search, propname)
if found:
return found
elif isinstance(v, list) and v and isinstance(v[0], LottieObject):
for obj in v:
found = obj.find(search, propname)
if found:
return found
return None
def find_all(self, type, predicate=None, include_self=True):
"""!
Find all child objects that match a predicate
@param type Type (or tuple of types) of the objects to match
@param predicate Function that returns true on the objects to find
@param include_self Whether should counsider `self` for a potential match
"""
if isinstance(self, type) and include_self:
if not predicate or predicate(self):
yield self
for prop in self._props:
v = prop.get(self)
if isinstance(v, LottieObject):
for found in v.find_all(type, predicate, True):
yield found
elif isinstance(v, list) and v and isinstance(v[0], LottieObject):
for child in v:
for found in child.find_all(type, predicate, True):
yield found
def clone(self):
obj = self.__class__()
for prop in self._props:
v = prop.get(self)
prop.set(obj, prop.clone_value(v))
return obj
def __str__(self):
return type(self).__name__
class Index:
"""!
@brief Simple iterator to generate increasing integers
"""
def __init__(self):
self._i = -1
def __next__(self):
self._i += 1
return self._i
class CustomObject(LottieObject):
"""!
Allows extending the Lottie shapes with custom Python classes
"""
wrapped_lottie = LottieObject
def __init__(self):
self.wrapped = self.wrapped_lottie()
@classmethod
def load(cls, lottiedict):
ld = lottiedict.copy()
classname = ld.pop("__pyclass")
modn, clsn = classname.rsplit(".", 1)
subcls = getattr(importlib.import_module(modn), clsn)
obj = subcls()
for prop in subcls._props:
prop.load_into(lottiedict, obj)
obj.wrapped = subcls.wrapped_lottie.load(ld)
return obj
def clone(self):
obj = self.__class__(**self.to_pyctor())
obj.wrapped = self.wrapped.clone()
return obj
def to_dict(self):
dict = self.wrapped.to_dict()
dict["__pyclass"] = "{0.__module__}.{0.__name__}".format(self.__class__)
dict.update(LottieObject.to_dict(self))
return dict
def _build_wrapped(self):
return self.wrapped_lottie()
def refresh(self):
self.wrapped = self._build_wrapped()
class ObjectVisitor:
DONT_RECURSE = object()
def __call__(self, lottie_object):
self._process(lottie_object)
def _process(self, lottie_object):
self.visit(lottie_object)
for p in lottie_object._props:
pval = p.get(lottie_object)
if self.visit_property(lottie_object, p, pval) is not self.DONT_RECURSE:
if isinstance(pval, LottieObject):
self._process(pval)
elif isinstance(pval, list) and pval and isinstance(pval[0], LottieObject):
for c in pval:
self._process(c)
def visit(self, object):
pass
def visit_property(self, object, property, value):
pass
+485
View File
@@ -0,0 +1,485 @@
import math
from .base import LottieObject, LottieProp
from .nvector import NVector
class BezierPoint:
def __init__(self, vertex, in_tangent=None, out_tangent=None):
self.vertex = vertex
self.in_tangent = in_tangent or NVector(0, 0)
self.out_tangent = out_tangent or NVector(0, 0)
def relative(self):
return self
@classmethod
def smooth(cls, point, in_tangent):
return cls(point, in_tangent, -in_tangent)
@classmethod
def from_absolute(cls, point, in_tangent=None, out_tangent=None):
if not in_tangent:
in_tangent = point.clone()
if not out_tangent:
out_tangent = point.clone()
return BezierPoint(point, in_tangent, out_tangent)
class BezierPointView:
"""
View for bezier point
"""
def __init__(self, bezier, index):
self.bezier = bezier
self.index = index
@property
def vertex(self):
return self.bezier.vertices[self.index]
@vertex.setter
def vertex(self, point):
self.bezier.vertices[self.index] = point
@property
def in_tangent(self):
return self.bezier.in_tangents[self.index]
@in_tangent.setter
def in_tangent(self, point):
self.bezier.in_tangents[self.index] = point
@property
def out_tangent(self):
return self.bezier.out_tangents[self.index]
@out_tangent.setter
def out_tangent(self, point):
self.bezier.out_tangents[self.index] = point
def relative(self):
return self
class AbsoluteBezierPointView(BezierPointView):
@property
def in_tangent(self):
return self.bezier.in_tangents[self.index] + self.vertex
@in_tangent.setter
def in_tangent(self, point):
self.bezier.in_tangents[self.index] = point - self.vertex
@property
def out_tangent(self):
return self.bezier.out_tangents[self.index] + self.vertex
@out_tangent.setter
def out_tangent(self, point):
self.bezier.out_tangents[self.index] = point - self.vertex
def relative(self):
return BezierPointView(self.bezier, self.index)
class BezierView:
def __init__(self, bezier, absolute=False):
self.bezier = bezier
self.is_absolute = absolute
def point(self, index):
if self.is_absolute:
return AbsoluteBezierPointView(self.bezier, index)
return BezierPointView(self.bezier, index)
def __len__(self):
return len(self.bezier.vertices)
def __getitem__(self, key):
if isinstance(key, slice):
return [
self.point(i)
for i in key
]
return self.point(key)
def __iter__(self):
for i in range(len(self)):
yield self.point(i)
def append(self, point):
if isinstance(point, NVector):
self.bezier.add_point(point.clone())
else:
bpt = point.relative()
self.bezier.add_point(bpt.vertex.clone(), bpt.in_tangent.clone(), bpt.out_tangent.clone())
@property
def absolute(self):
return BezierView(self.bezier, True)
## @ingroup Lottie
class Bezier(LottieObject):
"""!
Single bezier curve
"""
_props = [
LottieProp("closed", "c", bool, False),
LottieProp("in_tangents", "i", NVector, True),
LottieProp("out_tangents", "o", NVector, True),
LottieProp("vertices", "v", NVector, True),
]
def __init__(self):
## Closed property of shape
self.closed = False
## Cubic bezier handles for the segments before each vertex
self.in_tangents = []
## Cubic bezier handles for the segments after each vertex
self.out_tangents = []
## Bezier curve vertices.
self.vertices = []
#self.rel_tangents = rel_tangents
## More convent way to access points
self.points = BezierView(self)
def clone(self):
clone = Bezier()
clone.closed = self.closed
clone.in_tangents = [p.clone() for p in self.in_tangents]
clone.out_tangents = [p.clone() for p in self.out_tangents]
clone.vertices = [p.clone() for p in self.vertices]
#clone.rel_tangents = self.rel_tangents
return clone
def insert_point(self, index, pos, inp=NVector(0, 0), outp=NVector(0, 0)):
"""!
Inserts a point at the given index
@param index Index to insert the point at
@param pos Point to add
@param inp Tangent entering the point, as a vector relative to @p pos
@param outp Tangent exiting the point, as a vector relative to @p pos
@returns @c self, for easy chaining
"""
self.vertices.insert(index, pos)
self.in_tangents.insert(index, inp.clone())
self.out_tangents.insert(index, outp.clone())
#if not self.rel_tangents:
#self.in_tangents[-1] += pos
#self.out_tangents[-1] += pos
return self
def add_point(self, pos, inp=NVector(0, 0), outp=NVector(0, 0)):
"""!
Appends a point to the curve
@see insert_point
"""
self.insert_point(len(self.vertices), pos, inp, outp)
return self
def add_smooth_point(self, pos, inp):
"""!
Appends a point with symmetrical tangents
@see insert_point
"""
self.add_point(pos, inp, -inp)
return self
def close(self, closed=True):
"""!
Updates self.closed
@returns @c self, for easy chaining
"""
self.closed = closed
return self
def point_at(self, t):
"""!
@param t A value between 0 and 1, percentage along the length of the curve
@returns The point at @p t in the curve
"""
i, t = self._index_t(t)
points = self._bezier_points(i, True)
return self._solve_bezier(t, points)
def tangent_angle_at(self, t):
i, t = self._index_t(t)
points = self._bezier_points(i, True)
n = len(points) - 1
if n > 0:
delta = sum((
(points[i+1] - points[i]) * n * self._solve_bezier_coeff(i, n - 1, t)
for i in range(n)
), NVector(0, 0))
return math.atan2(delta.y, delta.x)
return 0
def _split(self, t):
i, t = self._index_t(t)
cub = self._bezier_points(i, True)
split1, split2 = self._split_segment(t, cub)
return i, split1, split2
def _split_segment(self, t, cub):
if len(cub) == 2:
k = self._solve_bezier_step(t, cub)[0]
split1 = [cub[0], NVector(0, 0), NVector(0, 0), k]
split2 = [k, NVector(0, 0), NVector(0, 0), cub[-1]]
return split1, split2
if len(cub) == 3:
quad = cub
else:
quad = self._solve_bezier_step(t, cub)
lin = self._solve_bezier_step(t, quad)
k = self._solve_bezier_step(t, lin)[0]
split1 = [cub[0], quad[0]-cub[0], lin[0]-k, k]
split2 = [k, lin[-1]-k, quad[-1]-cub[-1], cub[-1]]
return split1, split2
def split_at(self, t):
"""!
Get two pieces out of a Bezier curve
@param t A value between 0 and 1, percentage along the length of the curve
@returns Two Bezier objects that correspond to self, but split at @p t
"""
i, split1, split2 = self._split(t)
seg1 = Bezier()
seg2 = Bezier()
for j in range(i):
seg1.add_point(self.vertices[j].clone(), self.in_tangents[j].clone(), self.out_tangents[j].clone())
for j in range(i+2, len(self.vertices)):
seg2.add_point(self.vertices[j].clone(), self.in_tangents[j].clone(), self.out_tangents[j].clone())
seg1.add_point(split1[0], self.in_tangents[i].clone(), split1[1])
seg1.add_point(split1[3], split1[2], split2[1])
seg2.insert_point(0, split2[0], split1[2], split2[1])
seg2.insert_point(1, split2[3], split2[2], self.out_tangents[i+1].clone())
return seg1, seg2
def segment(self, t1, t2):
"""!
Splits a Bezier in two points and returns the segment between the
@param t1 A value between 0 and 1, percentage along the length of the curve
@param t2 A value between 0 and 1, percentage along the length of the curve
@returns Bezier object that correspond to the segment between @p t1 and @p t2
"""
if self.closed and self.vertices and self.vertices[-1] != self.vertices[0]:
copy = self.clone()
copy.add_point(self.vertices[0])
copy.closed = False
return copy.segment(t1, t2)
if t1 > 1:
t1 = 1
if t2 > 1:
t2 = 1
if t1 > t2:
t1, t2 = t2, t1
elif t1 == t2:
seg = Bezier()
p = self.point_at(t1)
seg.add_point(p)
seg.add_point(p)
return seg
seg1, seg2 = self.split_at(t1)
t2p = (t2-t1) / (1-t1)
seg3, seg4 = seg2.split_at(t2p)
return seg3
def split_self_multi(self, positions):
"""!
Adds more points to the Bezier
@param positions list of percentages along the curve
"""
if not len(positions):
return
t1 = positions[0]
seg1, seg2 = self.split_at(t1)
self.vertices = []
self.in_tangents = []
self.out_tangents = []
self.vertices = seg1.vertices[:-1]
self.in_tangents = seg1.in_tangents[:-1]
self.out_tangents = seg1.out_tangents[:-1]
for t2 in positions[1:]:
t = (t2-t1) / (1-t1)
seg1, seg2 = seg2.split_at(t)
t1 = t
self.vertices += seg1.vertices[:-1]
self.in_tangents += seg1.in_tangents[:-1]
self.out_tangents += seg1.out_tangents[:-1]
self.vertices += seg2.vertices
self.in_tangents += seg2.in_tangents
self.out_tangents += seg2.out_tangents
def split_each_segment(self):
"""!
Adds a point in the middle of the segment between every pair of points in the Bezier
"""
vertices = self.vertices
in_tangents = self.in_tangents
out_tangents = self.out_tangents
self.vertices = []
self.in_tangents = []
self.out_tangents = []
for i in range(len(vertices)-1):
tocut = [vertices[i], out_tangents[i]+vertices[i], in_tangents[i+1]+vertices[i+1], vertices[i+1]]
split1, split2 = self._split_segment(0.5, tocut)
if i:
self.out_tangents[-1] = split1[1]
else:
self.add_point(vertices[0], in_tangents[0], split1[1])
self.add_point(split1[3], split1[2], split2[1])
self.add_point(vertices[i+1], split2[2], NVector(0, 0))
def split_self_chunks(self, n_chunks):
"""!
Adds points the Bezier, splitting it into @p n_chunks additional chunks.
"""
splits = [i/n_chunks for i in range(1, n_chunks)]
return self.split_self_multi(splits)
def _bezier_points(self, i, optimize):
v1 = self.vertices[i].clone()
v2 = self.vertices[i+1].clone()
points = [v1]
t1 = self.out_tangents[i].clone()
if not optimize or t1.length != 0:
points.append(t1+v1)
t2 = self.in_tangents[i+1].clone()
if not optimize or t1.length != 0:
points.append(t2+v2)
points.append(v2)
return points
def _solve_bezier_step(self, t, points):
next = []
p1 = points[0]
for p2 in points[1:]:
next.append(p1 * (1-t) + p2 * t)
p1 = p2
return next
def _solve_bezier_coeff(self, i, n, t):
return (
math.factorial(n) / (math.factorial(i) * math.factorial(n - i)) # (n choose i)
* (t ** i) * ((1 - t) ** (n-i))
)
def _solve_bezier(self, t, points):
n = len(points) - 1
if n > 0:
return sum((
points[i] * self._solve_bezier_coeff(i, n, t)
for i in range(n+1)
), NVector(0, 0))
#while len(points) > 1:
#points = self._solve_bezier_step(t, points)
return points[0]
def _index_t(self, t):
if t <= 0:
return 0, 0
if t >= 1:
return len(self.vertices)-2, 1
n = len(self.vertices)-1
for i in range(n):
if (i+1) / n > t:
break
return i, (t - (i/n)) * n
def reverse(self):
"""!
Reverses the Bezier curve
"""
self.vertices = list(reversed(self.vertices))
out_tangents = list(reversed(self.in_tangents))
in_tangents = list(reversed(self.out_tangents))
self.in_tangents = in_tangents
self.out_tangents = out_tangents
"""def to_absolute(self):
if self.rel_tangents:
self.rel_tangents = False
for i in range(len(self.vertices)):
p = self.vertices[i]
self.in_tangents[i] += p
self.out_tangents[i] += p
return self"""
def rounded(self, round_distance):
cloned = Bezier()
cloned.closed = self.closed
round_corner = 0.5519
def _get_vt(closest_index):
closer_v = self.vertices[closest_index]
distance = (current - closer_v).length
new_pos_perc = min(distance/2, round_distance) / distance if distance else 0
vert = current + (closer_v - current) * new_pos_perc
tan = - (vert - current) * round_corner
return vert, tan
for i, current in enumerate(self.vertices):
if not self.closed and (i == 0 or i == len(self.points) - 1):
cloned.points.append(self.points[i])
else:
vert1, out_t = _get_vt(i - 1)
cloned.add_point(vert1, NVector(0, 0), out_t)
vert2, in_t = _get_vt((i+1) % len(self.points))
cloned.add_point(vert2, in_t, NVector(0, 0))
return cloned
def scale(self, amount):
for vl in (self.vertices, self.in_tangents, self.out_tangents):
for v in vl:
v *= amount
def lerp(self, other, t):
if len(other.vertices) != len(self.vertices):
if t < 1:
return self.clone()
return other.clone()
bez = Bezier()
bez.closed = self.closed
for vlist_name in ["vertices", "in_tangents", "out_tangents"]:
vlist = getattr(self, vlist_name)
olist = getattr(other, vlist_name)
out = getattr(bez, vlist_name)
for v, o in zip(vlist, olist):
out.append(v.lerp(o, t))
return bez
def rough_length(self):
if len(self.vertices) < 2:
return 0
last = self.vertices[0]
length = 0
for v in self.vertices[1:]:
length += (v-last).length
last = v
if self.closed:
length += (last-self.vertices[0]).length
return length
+447
View File
@@ -0,0 +1,447 @@
import enum
import math
import colorsys
from .nvector import NVector
def from_uint8(r, g, b, a=255):
return Color(r, g, b, a) / 255
class ColorMode(enum.Enum):
## sRGB, Components in [0, 1]
RGB = enum.auto()
## HSV, components in [0, 1]
HSV = enum.auto()
## HSL, components in [0, 1]
HSL = enum.auto()
## CIE XYZ with Illuminant D65. Components in [0, 1]
XYZ = enum.auto()
## CIE L*u*v*
LUV = enum.auto()
## CIE Lch(uv), polar version of LUV where C is the radius and H an angle in radians
LCH_uv = enum.auto()
## CIE L*a*b*
LAB = enum.auto()
## CIE LCh(ab), polar version of LAB where C is the radius and H an angle in radians
#LCH_ab = enum.auto()
def _clamp(x):
return max(0, min(1, x))
class Conversion:
_conv_paths = {
(ColorMode.RGB, ColorMode.RGB): [],
(ColorMode.RGB, ColorMode.HSV): [],
(ColorMode.RGB, ColorMode.HSL): [],
(ColorMode.RGB, ColorMode.XYZ): [],
(ColorMode.RGB, ColorMode.LUV): [ColorMode.XYZ],
(ColorMode.RGB, ColorMode.LAB): [ColorMode.XYZ],
(ColorMode.RGB, ColorMode.LCH_uv): [ColorMode.XYZ, ColorMode.LUV],
#(ColorMode.RGB, ColorMode.LCH_ab): [ColorMode.XYZ, ColorMode.LAB],
(ColorMode.HSV, ColorMode.RGB): [],
(ColorMode.HSV, ColorMode.HSV): [],
(ColorMode.HSV, ColorMode.HSL): [],
(ColorMode.HSV, ColorMode.XYZ): [ColorMode.RGB],
(ColorMode.HSV, ColorMode.LUV): [ColorMode.RGB, ColorMode.XYZ],
(ColorMode.HSV, ColorMode.LAB): [ColorMode.RGB, ColorMode.XYZ],
(ColorMode.HSV, ColorMode.LCH_uv): [ColorMode.RGB, ColorMode.XYZ, ColorMode.LUV],
#(ColorMode.HSV, ColorMode.LCH_ab): [ColorMode.RGB, ColorMode.XYZ, ColorMode.LAB],
(ColorMode.HSL, ColorMode.RGB): [],
(ColorMode.HSL, ColorMode.HSV): [],
(ColorMode.HSL, ColorMode.HSL): [],
(ColorMode.HSL, ColorMode.XYZ): [ColorMode.RGB],
(ColorMode.HSL, ColorMode.LUV): [ColorMode.RGB, ColorMode.XYZ],
(ColorMode.HSL, ColorMode.LAB): [ColorMode.RGB, ColorMode.XYZ],
(ColorMode.HSL, ColorMode.LCH_uv): [ColorMode.RGB, ColorMode.XYZ, ColorMode.LUV],
#(ColorMode.HSL, ColorMode.LCH_ab): [ColorMode.RGB, ColorMode.XYZ, ColorMode.LAB],
(ColorMode.XYZ, ColorMode.RGB): [],
(ColorMode.XYZ, ColorMode.HSV): [ColorMode.RGB],
(ColorMode.XYZ, ColorMode.HSL): [ColorMode.RGB],
(ColorMode.XYZ, ColorMode.XYZ): [],
(ColorMode.XYZ, ColorMode.LUV): [],
(ColorMode.XYZ, ColorMode.LAB): [],
(ColorMode.XYZ, ColorMode.LCH_uv): [ColorMode.LUV],
#(ColorMode.XYZ, ColorMode.LCH_ab): [ColorMode.LAB],
(ColorMode.LCH_uv, ColorMode.RGB): [ColorMode.LUV, ColorMode.XYZ],
(ColorMode.LCH_uv, ColorMode.HSV): [ColorMode.LUV, ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LCH_uv, ColorMode.HSL): [ColorMode.LUV, ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LCH_uv, ColorMode.XYZ): [ColorMode.LUV],
(ColorMode.LCH_uv, ColorMode.LUV): [],
(ColorMode.LCH_uv, ColorMode.LAB): [ColorMode.LUV, ColorMode.XYZ],
(ColorMode.LCH_uv, ColorMode.LCH_uv): [],
#(ColorMode.LCH_uv, ColorMode.LCH_ab): [ColorMode.LUV, ColorMode.XYZ, ColorMode.LAB],
(ColorMode.LUV, ColorMode.RGB): [ColorMode.XYZ],
(ColorMode.LUV, ColorMode.HSV): [ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LUV, ColorMode.HSL): [ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LUV, ColorMode.XYZ): [],
(ColorMode.LUV, ColorMode.LUV): [],
(ColorMode.LUV, ColorMode.LAB): [ColorMode.XYZ],
(ColorMode.LUV, ColorMode.LCH_uv): [],
#(ColorMode.LUV, ColorMode.LCH_ab): [ColorMode.XYZ, ColorMode.LAB],
(ColorMode.LAB, ColorMode.RGB): [ColorMode.XYZ],
(ColorMode.LAB, ColorMode.HSV): [ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LAB, ColorMode.HSL): [ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LAB, ColorMode.XYZ): [],
(ColorMode.LAB, ColorMode.LUV): [ColorMode.XYZ],
(ColorMode.LAB, ColorMode.LAB): [],
(ColorMode.LAB, ColorMode.LCH_uv): [ColorMode.XYZ, ColorMode.LUV],
#(ColorMode.LAB, ColorMode.LCH_ab): [],
#(ColorMode.LCH_ab, ColorMode.RGB): [ColorMode.LAB, ColorMode.XYZ],
#(ColorMode.LCH_ab, ColorMode.HSV): [ColorMode.LAB, ColorMode.XYZ, ColorMode.RGB],
#(ColorMode.LCH_ab, ColorMode.HSL): [ColorMode.LAB, ColorMode.XYZ, ColorMode.RGB],
#(ColorMode.LCH_ab, ColorMode.XYZ): [ColorMode.LAB],
#(ColorMode.LCH_ab, ColorMode.LUV): [ColorMode.LAB, ColorMode.XYZ],
#(ColorMode.LCH_ab, ColorMode.LAB): [],
#(ColorMode.LCH_ab, ColorMode.LCH_uv): [ColorMode.LAB, ColorMode.XYZ, ColorMode.LUV],
#(ColorMode.LCH_ab, ColorMode.LCH_ab): [],
}
@staticmethod
def rgb_to_hsv(r, g, b):
return colorsys.rgb_to_hsv(r, g, b)
@staticmethod
def hsv_to_rgb(r, g, b):
return colorsys.hsv_to_rgb(r, g, b)
@staticmethod
def hsl_to_hsv(h, s_hsl, l):
v = l + s_hsl * min(l, 1 - l)
s_hsv = 0 if v == 0 else 2 - 2 * l / v
return (h, s_hsv, v)
@staticmethod
def hsv_to_hsl(h, s_hsv, v):
l = v - v * s_hsv / 2
s_hsl = 0 if l in (0, 1) else (v - l) / min(l, 1 - l)
return (h, s_hsl, l)
@staticmethod
def rgb_to_hsl(r, g, b):
h, l, s = colorsys.rgb_to_hls(r, g, b)
return (h, s, l)
@staticmethod
def hsl_to_rgb(h, s, l):
return colorsys.hls_to_rgb(h, l, s)
# http://w3.uqo.ca/missaoui/Publications/TRColorSpace.zip
#@staticmethod
#def rgb_to_hcl(r, g, b, gamma=3, y0=100):
#maxc = max(r, g, b)
#minc = min(r, g, b)
#if maxc > 0:
#alpha = 1/y0 * minc / maxc
#else:
#alpha = 0
#q = math.e ** (alpha * gamma)
#h = math.atan2(g - b, r - g)
#if h < 0:
#h += 2*math.pi
#h /= 2*math.pi
#c = q / 3 * (abs(r-g) + abs(g-b) + abs(b-r))
#l = (q * maxc + (q-1) * minc) / 2
#return (h, c, l)
#@staticmethod
#def hcl_to_rgb(h, c, l, gamma=3, y0=100):
#h *= 2*math.pi
#q = math.e ** ((1 - 2*c / 4*l) * gamma / y0)
#minc = (4*l - 3*c) / (4*q - 2)
#maxc = minc + 3*c / 2*q
#if h <= math.pi * 1 / 3:
#tan = math.tan(3/2*h)
#r = maxc
#b = minc
#g = (r * tan + b) / (1 + tan)
#elif h <= math.pi * 2 / 3:
#tan = math.tan(3/4*(h-math.pi))
#g = maxc
#b = minc
#r = (g * (1+tan) - b) / tan
#elif h <= math.pi * 3 / 3:
#tan = math.tan(3/4*(h-math.pi))
#g = maxc
#r = minc
#b = g * (1+tan) - r * tan
#elif h <= math.pi * 4 / 3:
#tan = math.tan(3/2*(h+math.pi))
#b = maxc
#r = minc
#g = (r * tan + b) / (1 + tan)
#elif h <= math.pi * 5 / 3:
#tan = math.tan(3/4*h)
#b = maxc
#g = minc
#r = (g * (1+tan) - b) / tan
#else:
#tan = math.tan(3/4*h)
#r = maxc
#g = minc
#b = g * (1+tan) - r * tan
#return _clamp(r), _clamp(g), _clamp(b)
@staticmethod
def rgb_to_xyz(r, g, b):
def _gamma(v):
return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4
rgb = (_gamma(r), _gamma(g), _gamma(b))
matrix = [
[0.4124564, 0.3575761, 0.1804375],
[0.2126729, 0.7151522, 0.0721750],
[0.0193339, 0.1191920, 0.9503041],
]
return tuple(
sum(rgb[i] * c for i, c in enumerate(row))
for row in matrix
)
@staticmethod
def xyz_to_rgb(x, y, z):
def _gamma1(v):
return _clamp(v * 12.92 if v <= 0.0031308 else v ** (1/2.4) * 1.055 - 0.055)
matrix = [
[+3.2404542, -1.5371385, -0.4985314],
[-0.9692660, +1.8760108, +0.0415560],
[+0.0556434, -0.2040259, +1.0572252],
]
xyz = (x, y, z)
return tuple(map(_gamma1, (
sum(xyz[i] * c for i, c in enumerate(row))
for row in matrix
)))
@staticmethod
def xyz_to_luv(x, y, z):
u1r = 0.2009
v1r = 0.4610
yr = 100
kap = (29/3)**3
eps = (6/29)**3
try:
u1 = 4*x / (x + 15*y + 3*z)
v1 = 9*y / (x + 15*y + 3*z)
except ZeroDivisionError:
return 0, 0, 0
y_r = y/yr
l = 166 * y_r ** (1/3) - 16 if y_r > eps else kap * y_r
u = 13 * l * (u1 - u1r)
v = 13 * l * (v1 - v1r)
return l, u, v
@staticmethod
def luv_to_xyz(l, u, v):
u1r = 0.2009
v1r = 0.4610
yr = 100
kap = (29/3)**3
if l == 0:
u1 = u1r
v1 = v1r
else:
u1 = u / (13 * l) + u1r
v1 = v / (13 * l) + v1r
y = yr * l / kap if l <= 8 else yr * ((l + 16) / 116) ** 3
x = y * 9*u1 / (4*v1)
z = y * (12 - 3*u1 - 20*v1) / (4*v1)
return x, y, z
@staticmethod
def luv_to_lch_uv(l, u, v):
c = math.hypot(u, v)
h = math.atan2(v, u)
if h < 0:
h += math.tau
return l, c, h
@staticmethod
def lch_uv_to_luv(l, c, h):
u = math.cos(h) * c
v = math.sin(h) * c
return l, u, v
@staticmethod
def xyz_to_lab(x, y, z):
# D65 Illuminant aka sRGB(1,1,1)
xn = 0.950489
yn = 1
zn = 108.8840
delta = 6 / 29
def f(t):
return t ** (1/3) if t > delta ** 3 else t / (3*delta**2) + 4/29
fy = f(y/yn)
l = 116 * fy - 16
a = 500 * (f(x/xn) - fy)
b = 200 * (fy - f(z/zn))
return l, a, b
@staticmethod
def lab_to_xyz(l, a, b):
# D65 Illuminant aka sRGB(1,1,1)
xn = 0.950489
yn = 1
zn = 108.8840
delta = 6 / 29
def f1(t):
return t**3 if t > delta else 3*delta**2*(t-4/29)
l1 = (l+16) / 116
x = xn * f1(l1+a/500)
y = yn * f1(l1)
z = zn * f1(l1-b/200)
return x, y, z
#@staticmethod
#def lab_to_lch_ab(l, a, b):
#c = math.hypot(a, b)
#h = math.atan2(b, a)
#if h < 0:
#h += math.tau
#return l, c, h
#@staticmethod
#def lch_ab_to_lab(l, c, h):
#a = math.cos(h) * c
#b = math.sin(h) * c
#return l, a, b
@staticmethod
def conv_func(mode_from, mode_to):
return getattr(Conversion, "%s_to_%s" % (mode_from.name.lower(), mode_to.name.lower()), None)
@staticmethod
def convert(tuple, mode_from, mode_to):
if mode_from == mode_to:
return tuple
if len(tuple) == 4:
alpha = tuple[3]
tuple = tuple[:3]
else:
alpha = None
func = Conversion.conv_func(mode_from, mode_to)
if func:
return func(*tuple)
if (mode_from, mode_to) in Conversion._conv_paths:
steps = Conversion._conv_paths[(mode_from, mode_to)] + [mode_to]
for step in steps:
func = Conversion.conv_func(mode_from, step)
if not func:
raise ValueError("Missing definition for conversion from %s to %s" % (mode_from, step))
tuple = func(*tuple)
mode_from = step
if alpha is not None:
tuple += (alpha,)
return tuple
raise ValueError("No conversion path from %s to %s" % (mode_from, mode_to))
class Color(NVector):
Mode = ColorMode
def __init__(self, c1=0, c2=0, c3=0, a=1, *, mode=ColorMode.RGB):
if isinstance(a, ColorMode):
raise TypeError("Please update the Color constructor")
super().__init__(c1, c2, c3, a)
self._mode = mode
@property
def mode(self):
return self._mode
def convert(self, v):
if v == self._mode:
return self
self.components = list(Conversion.convert(self.components, self._mode, v))
self._mode = v
return self
def clone(self):
return Color(*self.components, mode=self._mode)
def converted(self, mode):
return self.clone().convert(mode)
def to_rgb(self):
return self.converted(ColorMode.RGB)
def __repr__(self):
return "<%s %s [%.3f, %.3f, %.3f, %.3f]>" % (
(self.__class__.__name__, self.mode.name) + tuple(self.components)
)
def component_names(self):
comps = None
if self._mode == ColorMode.RGB:
comps = ({"r", "red"}, {"g", "green"}, {"b", "blue"})
elif self._mode == ColorMode.HSV:
comps = ({"h", "hue"}, {"s", "saturation"}, {"v", "value"})
elif self._mode == ColorMode.HSL:
comps = ({"h", "hue"}, {"s", "saturation"}, {"l", "lightness"})
elif self._mode == ColorMode.LCH_uv: # in (ColorMode.LCH_uv, ColorMode.LCH_ab):
comps = ({"l", "luma", "luminance"}, {"c", "choma"}, {"h", "hue"})
elif self._mode == ColorMode.XYZ:
comps = "xyz"
elif self._mode == ColorMode.LUV:
comps = "luv"
elif self._mode == ColorMode.LAB:
comps = "lab"
return comps
def _attrindex(self, name):
comps = self.component_names()
if comps:
for i, vals in enumerate(comps):
if name in vals:
return i
return None
def __getattr__(self, name):
if name not in vars(self) and name not in {"_mode", "components"}:
i = self._attrindex(name)
if i is not None:
return self.components[i]
raise AttributeError(name)
def __setattr__(self, name, value):
if name not in vars(self) and name not in {"_mode", "components"}:
i = self._attrindex(name)
if i is not None:
self.components[i] = value
return
return super().__setattr__(name, value)
+80
View File
@@ -0,0 +1,80 @@
from .base import LottieObject, Index, LottieProp
from .layers import Layer
## @ingroup Lottie
class Composition(LottieObject):
"""!
Base class for layer holders
"""
_props = [
LottieProp("layers", "layers", Layer, True),
]
def __init__(self):
## List of Composition Layers
self.layers = [] # ShapeLayer, SolidLayer, CompLayer, ImageLayer, NullLayer, TextLayer
self._index_gen = Index()
def layer(self, index):
for layer in self.layers:
if layer.index == index:
return layer
raise IndexError("No layer %s" % index)
def add_layer(self, layer: Layer):
"""!
@brief Appends a layer to the composition
@see insert_layer
"""
return self.insert_layer(len(self.layers), layer)
@classmethod
def load(cls, lottiedict):
obj = super().load(lottiedict)
obj._fixup()
return obj
def _fixup(self):
for layer in self.layers:
layer.composition = self
def insert_layer(self, index, layer: Layer):
"""!
@brief Inserts a layer to the composition
@note Layers added first will be rendered on top of later layers
"""
self.layers.insert(index, layer)
self.prepare_layer(layer)
return layer
def prepare_layer(self, layer: Layer):
layer.composition = self
if layer.index is None:
layer.index = next(self._index_gen)
self._on_prepare_layer(layer)
def _on_prepare_layer(self, layer):
raise NotImplementedError
def clone(self):
c = super().clone()
c._index_gen._i = self._index_gen._i
return c
def remove_layer(self, layer: Layer):
"""!
@brief Removes a layer (and all of its children) from this composition
@param layer Layer to be removed
"""
if layer.composition is not self:
return
children = list(layer.children)
layer.composition = None
self.layers.remove(layer)
for c in children:
self.remove_layer(c)
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
import math
from .base import LottieObject, LottieProp, PseudoList, PseudoBool
## @ingroup Lottie
class KeyframeBezierHandle(LottieObject):
"""!
Bezier handle for keyframe interpolation
"""
_props = [
LottieProp("x", "x", list=PseudoList),
LottieProp("y", "y", list=PseudoList),
]
def __init__(self, x=0, y=0):
## x position of the handle.
## This represents the change in time of the keyframe
self.x = x
## y position of the handle.
## This represents the change in value of the keyframe
self.y = y
class Linear:
"""!
Linear easing, the value will change from start to end in a straight line
"""
def __call__(self, keyframe):
keyframe.out_value = KeyframeBezierHandle(
0,
0
)
keyframe.in_value = KeyframeBezierHandle(
1,
1
)
class EaseIn:
"""!
The value lingers near the start before accelerating towards the end
"""
def __init__(self, delay=1/3):
self.delay = delay
def __call__(self, keyframe):
keyframe.out_value = KeyframeBezierHandle(
self.delay,
0
)
keyframe.in_value = KeyframeBezierHandle(
1,
1
)
class EaseOut:
"""!
The value starts fast before decelerating towards the end
"""
def __init__(self, delay=1/3):
self.delay = delay
def __call__(self, keyframe):
keyframe.out_value = KeyframeBezierHandle(
0,
0
)
keyframe.in_value = KeyframeBezierHandle(
1-self.delay,
1
)
class Jump:
"""!
Jumps to the end value at the end of the keyframe
"""
def __call__(self, keyframe):
keyframe.jump = True
class Sigmoid:
"""!
Combines the effects of EaseIn and EaseOut
"""
def __init__(self, delay=1/3):
self.delay = delay
def __call__(self, keyframe):
keyframe.out_value = KeyframeBezierHandle(
self.delay,
0
)
keyframe.in_value = KeyframeBezierHandle(
1 - self.delay,
1
)
class Split:
"""
Uses different easing methods for in/out
"""
def __init__(self, out_ease, in_ease):
self.out_ease = out_ease
self.in_ease = in_ease
def __call__(self, keyframe):
self.out_ease(keyframe)
t = keyframe.out_value
self.in_ease(keyframe)
keyframe.out_value = t
+441
View File
@@ -0,0 +1,441 @@
from .base import LottieObject, LottieProp, PseudoBool
from .properties import Value, MultiDimensional, ColorValue
from .nvector import NVector
from .color import Color
#5: EffectsManager,
#11: MaskEffect,
class EffectValue(LottieObject):
"""!
Value for an effect
"""
## %Effect value type.
type = None
_classses = {}
_props = [
LottieProp("effect_index", "ix", int, False),
#LottieProp("match_name", "mn", str, False),
LottieProp("name", "nm", str, False),
LottieProp("type", "ty", int, False),
]
def __init__(self):
## Effect Index. Used for expressions.
self.effect_index = None
## After Effect's Name. Used for expressions.
self.name = None
"""
## After Effect's Match Name. Used for expressions.
self.match_name = ""
"""
@classmethod
def _load_get_class(cls, lottiedict):
if not EffectValue._classses:
EffectValue._classses = {
sc.type: sc
for sc in EffectValue.__subclasses__()
}
return EffectValue._classses[lottiedict["ty"]]
def __str__(self):
return self.name or super().__str__()
## @ingroup Lottie
class Effect(LottieObject):
"""!
Layer effect
"""
## %Effect type.
type = None
_classses = {}
_props = [
LottieProp("effect_index", "ix", int, False),
#LottieProp("match_name", "mn", str, False),
LottieProp("name", "nm", str, False),
LottieProp("type", "ty", int, False),
LottieProp("effects", "ef", EffectValue, True),
]
_effects = []
def __init__(self, *args, **kwargs):
## Effect Index. Used for expressions.
self.effect_index = None
## After Effect's Name. Used for expressions.
self.name = None
## Effect parameters
self.effects = self._load_values(*args, **kwargs)
"""
## After Effect's Match Name. Used for expressions.
self.match_name = ""
"""
@classmethod
def _load_get_class(cls, lottiedict):
if not Effect._classses:
Effect._classses = {
sc.type: sc
for sc in Effect.__subclasses__()
}
type = lottiedict["ty"]
if type in Effect._classses:
return Effect._classses[type]
else:
return Effect
def _load_values(self, *args, **kwargs):
values = []
for i, (name, type) in enumerate(self._effects):
val = []
if len(args) > i:
val = [args[i]]
if name in kwargs:
val = [kwargs[name]]
values.append(type(*val))
return values
def __getattr__(self, key):
for i, (name, type) in enumerate(self._effects):
if name == key:
return self.effects[i].value
return super().__getattr__(key)
def __str__(self):
return self.name or super().__str__()
## @ingroup Lottie
## @ingroup LottieCheck
class EffectNoValue(EffectValue):
_props = []
## @ingroup Lottie
class EffectValueSlider(EffectValue):
_props = [
LottieProp("value", "v", Value, False),
]
## %Effect type.
type = 0
def __init__(self, value=0):
EffectValue.__init__(self)
## Effect value.
self.value = Value(value)
## @ingroup Lottie
class EffectValueAngle(EffectValue):
_props = [
LottieProp("value", "v", Value, False),
]
## %Effect type.
type = 1
def __init__(self, angle=0):
EffectValue.__init__(self)
## Effect value.
self.value = Value(angle)
## @ingroup Lottie
class EffectValueColor(EffectValue):
_props = [
LottieProp("value", "v", ColorValue, False),
]
## %Effect type.
type = 2
def __init__(self, value=Color(0, 0, 0)):
EffectValue.__init__(self)
## Effect value.
self.value = ColorValue(value)
## @ingroup Lottie
class EffectValuePoint(EffectValue):
_props = [
LottieProp("value", "v", MultiDimensional, False),
]
## %Effect type.
type = 3
def __init__(self, value=NVector(0, 0)):
EffectValue.__init__(self)
## Effect value.
self.value = MultiDimensional(value)
## @ingroup Lottie
class EffectValueCheckbox(EffectValue):
_props = [
LottieProp("value", "v", Value, False),
]
## %Effect type.
type = 4
def __init__(self, value=0):
EffectValue.__init__(self)
## Effect value.
self.value = Value(value)
## @ingroup Lottie
## @ingroup LottieCheck
## Lottie-web ignores these
class IgnoredValue(EffectValue):
_props = [
LottieProp("value", "v", float, False),
]
## %Effect type.
type = 6
def __init__(self, value=0):
EffectValue.__init__(self)
## Effect value.
self.value = value
## @ingroup Lottie
## @ingroup LottieCheck
class EffectValueDropDown(EffectValue):
_props = [
LottieProp("value", "v", Value, False),
]
## %Effect type.
type = 7
def __init__(self, value=0):
EffectValue.__init__(self)
## Effect value.
self.value = Value(value)
## @ingroup Lottie
## @ingroup LottieCheck
class EffectValueLayer(EffectValue):
_props = [
LottieProp("value", "v", Value, False),
]
## %Effect type.
type = 10
def __init__(self):
EffectValue.__init__(self)
## Effect value.
self.value = Value()
## @ingroup Lottie
class FillEffect(Effect):
"""!
Replaces the whole layer with the given color
@note Opacity is in [0, 1]
"""
_effects = [
("00", EffectValuePoint),
("01", EffectValueDropDown),
("color", EffectValueColor),
("03", EffectValueDropDown),
("04", EffectValueSlider),
("05", EffectValueSlider),
("opacity", EffectValueSlider),
]
## %Effect type.
type = 21
## @ingroup Lottie
class StrokeEffect(Effect):
_effects = [
("00", EffectValueColor),
("01", EffectValueCheckbox),
("02", EffectValueCheckbox),
("color", EffectValueColor),
("04", EffectValueSlider),
("05", EffectValueSlider),
("06", EffectValueSlider),
("07", EffectValueSlider),
("08", EffectValueSlider),
("09", EffectValueDropDown),
("type", EffectValueDropDown),
]
## %Effect type.
type = 22
## @ingroup Lottie
class TritoneEffect(Effect):
"""!
Maps layers colors based on bright/mid/dark colors
"""
_effects = [
("bright", EffectValueColor),
("mid", EffectValueColor),
("dark", EffectValueColor),
]
## %Effect type.
type = 23
"""
## @ingroup Lottie
## @ingroup LottieCheck
class GroupEffect(Effect):
_props = [
LottieProp("enabled", "en", PseudoBool, False),
]
def __init__(self):
Effect.__init__(self)
## Enabled AE property value
self.enabled = True
"""
## @ingroup Lottie
## @ingroup LottieCheck
class ProLevelsEffect(Effect):
_effects = [
("00", EffectValueDropDown),
("01", EffectNoValue),
("02", EffectNoValue),
("comp_inblack", EffectValueSlider),
("comp_inwhite", EffectValueSlider),
("comp_gamma", EffectValueSlider),
("comp_outblack", EffectValueSlider),
("comp_outwhite", EffectNoValue),
("08", EffectNoValue),
("09", EffectValueSlider),
("r_inblack", EffectValueSlider),
("r_inwhite", EffectValueSlider),
("r_gamma", EffectValueSlider),
("r_outblack", EffectValueSlider),
("r_outwhite", EffectNoValue),
("15", EffectValueSlider),
("16", EffectValueSlider),
("g_inblack", EffectValueSlider),
("g_inwhite", EffectValueSlider),
("g_gamma", EffectValueSlider),
("g_outblack", EffectValueSlider),
("g_outwhite", EffectNoValue),
("22", EffectValueSlider),
("b3", EffectValueSlider),
("b_inblack", EffectValueSlider),
("b_inwhite", EffectValueSlider),
("b_gamma", EffectValueSlider),
("b_outblack", EffectValueSlider),
("b_outwhite", EffectNoValue),
("29", EffectValueSlider),
("a_inblack", EffectValueSlider),
("a_inwhite", EffectValueSlider),
("a_gamma", EffectValueSlider),
("a_outblack", EffectValueSlider),
("a_outwhite", EffectNoValue),
]
## %Effect type.
type = 24
## @ingroup Lottie
class TintEffect(Effect):
"""!
Colorizes the layer
@note Opacity is in [0, 100]
"""
_effects = [
("color_black", EffectValueColor),
("color_white", EffectValueColor),
("opacity", EffectValueSlider),
]
## %Effect type.
type = 20
## @ingroup Lottie
class DropShadowEffect(Effect):
"""!
Adds a shadow to the layer
@note Opacity is in [0, 255]
"""
_effects = [
("color", EffectValueColor),
("opacity", EffectValueSlider),
("angle", EffectValueAngle),
("distance", EffectValueSlider),
("blur", EffectValueSlider),
]
## %Effect type.
type = 25
## @ingroup Lottie
## @ingroup LottieCheck
class Matte3Effect(Effect):
_effects = [
("index", EffectValueSlider),
]
## %Effect type.
type = 28
## @ingroup Lottie
class GaussianBlurEffect(Effect):
"""!
Gaussian blur
"""
_effects = [
("sigma", EffectValueSlider),
("dimensions", EffectValueSlider),
("wrap", EffectValueCheckbox),
]
## %Effect type.
type = 29
#class ChangeColorEffect(Effect):
#"""!
#Gaussian blur
#"""
#_effects = [
#("view", EffectValueDropDown),
#("hue", EffectValueSlider),
#("lightness", EffectValueSlider),
#("saturation", EffectValueSlider),
#("color_to_change", EffectValueColor),
#("tolerance", EffectValueSlider),
#("softness", EffectValueSlider),
#("match", EffectValueDropDown),
#("invert_mask", EffectValueDropDown),
#]
### %Effect type.
#type = 29
## @ingroup Lottie
class ChangeToColorEffect(Effect):
"""!
Change to color
"""
_effects = [
("from_color", EffectValueColor),
("to_color", EffectValueColor),
("change", EffectValueDropDown),
("change_by", EffectValueDropDown),
("tolerance", IgnoredValue),
("hue", EffectValueSlider),
("lightness", EffectValueSlider),
("saturation", EffectValueSlider),
("saturation_", IgnoredValue),
("softness", EffectValueSlider),
("view_correction", EffectValueDropDown),
]
## %Effect type.
type = 5
+39
View File
@@ -0,0 +1,39 @@
from .base import LottieEnum
## @ingroup Lottie
class TestBased(LottieEnum):
Characters = 1
CharacterExcludingSpaces = 2
Words = 3
Lines = 4
@classmethod
def default(cls):
return cls.Characters
## @ingroup Lottie
class TextShape(LottieEnum):
Square = 1
RampUp = 2
RampDown = 3
Triangle = 4
Round = 5
Smooth = 6
@classmethod
def default(cls):
return cls.Square
## @ingroup Lottie
class TextGrouping(LottieEnum):
Characters = 1
Word = 2
Line = 3
All = 4
@classmethod
def default(cls):
return cls.Characters
+125
View File
@@ -0,0 +1,125 @@
import math
from .base import LottieObject, LottieProp, LottieEnum
from .properties import MultiDimensional, Value, NVector, ShapeProperty, PositionValue
## @ingroup Lottie
class Transform(LottieObject):
"""!
Layer transform
"""
_props = [
LottieProp("anchor_point", "a", MultiDimensional, False),
LottieProp("position", "p", PositionValue, False),
LottieProp("scale", "s", MultiDimensional, False),
LottieProp("rotation", "r", Value, False),
LottieProp("opacity", "o", Value, False),
#LottieProp("position_x", "px", Value, False),
#LottieProp("position_y", "py", Value, False),
#LottieProp("position_z", "pz", Value, False),
LottieProp("skew", "sk", Value, False),
LottieProp("skew_axis", "sa", Value, False),
]
def __init__(self):
## Transform Anchor Point
self.anchor_point = MultiDimensional(NVector(0, 0))
## Transform Position
self.position = PositionValue(NVector(0, 0))
## Transform Scale
self.scale = MultiDimensional(NVector(100, 100))
## Transform Rotation
self.rotation = Value(0)
## Transform Opacity
self.opacity = Value(100)
"""
# Transform Position X
#self.position_x = Value()
## Transform Position Y
#self.position_y = Value()
## Transform Position Z
#self.position_z = Value()
"""
## Transform Skew
self.skew = Value(0)
## Transform Skew Axis.
## An angle, if 0 skews on the X axis, if 90 skews on the Y axis
self.skew_axis = Value(0)
def to_matrix(self, time, auto_orient=False):
from ..utils.transform import TransformMatrix
mat = TransformMatrix()
anchor = self.anchor_point.get_value(time) if self.anchor_point else NVector(0, 0)
mat.translate(-anchor.x, -anchor.y)
scale = self.scale.get_value(time) if self.scale else NVector(100, 100)
mat.scale(scale.x / 100, scale.y / 100)
skew = (self.skew.get_value(time) * math.pi / 180) if self.skew else 0
if skew != 0:
axis = (self.skew_axis.get_value(time) * math.pi / 180) if self.skew_axis else 0
mat.skew_from_axis(-skew, axis)
rot = (self.rotation.get_value(time) * math.pi / 180) if self.rotation else 0
if rot:
mat.rotate(-rot)
if auto_orient:
if self.position and self.position.animated:
ao_angle = self.position.get_tangent_angle(time)
mat.rotate(-ao_angle)
pos = self.position.get_value(time) if self.position else NVector(0, 0)
mat.translate(pos.x, pos.y)
return mat
## @ingroup Lottie
class MaskMode(LottieEnum):
"""!
How masks interact with each other
@see https://helpx.adobe.com/after-effects/using/alpha-channels-masks-mattes.html
"""
No = "n"
Add = "a"
Subtract = "s"
Intersect = "i"
## @note Not in lottie web
Lightent = "l"
## @note Not in lottie web
Darken = "d"
## @note Not in lottie web
Difference = "f"
## @ingroup Lottie
## @todo Implement SVG/SIF I/O
class Mask(LottieObject):
_props = [
LottieProp("inverted", "inv", bool, False),
LottieProp("name", "nm", str, False),
LottieProp("shape", "pt", ShapeProperty, False),
LottieProp("opacity", "o", Value, False),
LottieProp("mode", "mode", MaskMode, False),
LottieProp("dilate", "x", Value, False),
]
def __init__(self, bezier=None):
## Inverted Mask flag
self.inverted = False
## Mask name. Used for expressions and effects.
self.name = None
## Mask vertices
self.shape = ShapeProperty(bezier)
## Mask opacity.
self.opacity = Value(100)
## Mask mode. Not all mask types are supported.
self.mode = MaskMode.Intersect
self.dilate = Value(0)
def __str__(self):
return self.name or super().__str__()
+294
View File
@@ -0,0 +1,294 @@
import warnings
from .base import LottieObject, LottieProp, PseudoBool, LottieEnum
from .effects import Effect
from .helpers import Transform, Mask
from .shapes import ShapeElement
from .text import TextAnimatorData
from .properties import Value
## @ingroup Lottie
class BlendMode(LottieEnum):
Normal = 0
Multiply = 1
Screen = 2
Overlay = 3
Darken = 4
Lighten = 5
ColorDodge = 6
ColorBurn = 7
HardLight = 8
SoftLight = 9
Difference = 10
Exclusion = 11
Hue = 12
Saturation = 13
Color = 14
Luminosity = 15
## @ingroup Lottie
## @todo SVG masks
class MatteMode(LottieEnum):
Normal = 0
Alpha = 1
InvertedAlpha = 2
Luma = 3
InvertedLuma = 4
## @ingroup Lottie
class Layer(LottieObject):
_props = [
LottieProp("threedimensional", "ddd", PseudoBool, False),
LottieProp("hidden", "hd", bool, False),
LottieProp("type", "ty", int, False),
LottieProp("name", "nm", str, False),
LottieProp("parent_index", "parent", int, False),
LottieProp("stretch", "sr", float, False),
LottieProp("transform", "ks", Transform, False),
LottieProp("auto_orient", "ao", PseudoBool, False),
LottieProp("in_point", "ip", float, False),
LottieProp("out_point", "op", float, False),
LottieProp("start_time", "st", float, False),
LottieProp("blend_mode", "bm", BlendMode, False),
LottieProp("matte_mode", "tt", MatteMode, False),
LottieProp("index", "ind", int, False),
#LottieProp("css_class", "cl", str, False),
LottieProp("layer_html_id", "ln", str, False),
LottieProp("has_masks", "hasMask", bool, False),
LottieProp("masks", "masksProperties", Mask, True),
LottieProp("effects", "ef", Effect, True),
LottieProp("matte_target", "td", int, False),
]
## %Layer type.
## @see https://github.com/bodymovin/bodymovin-extension/blob/master/bundle/jsx/enums/layerTypes.jsx
type = None
_classses = {}
@property
def has_masks(self):
"""!
Whether the layer has some masks applied
"""
return bool(self.masks) if getattr(self, "masks") is not None else None
def __init__(self):
## Transform properties
self.transform = Transform()
## Auto-Orient along path AE property.
self.auto_orient = False
## 3d layer flag
self.threedimensional = False
## Hidden layer
self.hidden = None
## Layer index in AE. Used for parenting and expressions.
self.index = None
"""
# Parsed layer name used as html class on SVG/HTML renderer
#self.css_class = ""
# Parsed layer name used as html id on SVG/HTML renderer
#self.layer_html_id = ""
"""
## In Point of layer. Sets the initial frame of the layer.
self.in_point = None
## Out Point of layer. Sets the final frame of the layer.
self.out_point = None
## Start Time of layer. Sets the start time of the layer.
self.start_time = 0
## After Effects Layer Name. Used for expressions.
self.name = None
## List of Effects
self.effects = None
## Layer Time Stretching
self.stretch = 1
## Layer Parent. Uses ind of parent.
self.parent_index = None
## List of Masks
self.masks = None
## Blend Mode
self.blend_mode = BlendMode.Normal
## Matte mode, the layer will inherit the transparency from the layer above
self.matte_mode = None
self.matte_target = None
## Composition owning the layer, set by add_layer
self.composition = None
def add_child(self, layer):
if not self.composition or self.index is None:
raise Exception("Must set composition / index first")
self._child_inout_auto(layer)
self.composition.add_layer(layer)
layer.parent_index = self.index
return layer
def _child_inout_auto(self, layer):
if layer.in_point is None:
layer.in_point = self.in_point
if layer.out_point is None:
layer.out_point = self.out_point
@property
def parent(self):
if self.parent_index is None:
return None
return self.composition.layer(self.parent_index)
@parent.setter
def parent(self, layer):
if layer is None:
self.parent_index = None
else:
self.parent_index = layer.index
layer._child_inout_auto(self)
@property
def children(self):
for layer in self.composition.layers:
if layer.parent_index == self.index:
yield layer
@classmethod
def _load_get_class(cls, lottiedict):
if not Layer._classses:
Layer._classses = {
sc.type: sc
for sc in Layer.__subclasses__()
}
type_id = lottiedict["ty"]
if type_id not in Layer._classses:
warnings.warn("Unknown layer type: %s" % type_id)
return Layer
return Layer._classses[type_id]
def __repr__(self):
return "<%s %s %s>" % (type(self).__name__, self.index, self.name)
def __str__(self):
return "%s %s" % (
self.name or super().__str__(),
self.index if self.index is not None else ""
)
def remove(self):
"""!
@brief Removes this layer from the componsitin
"""
self.composition.remove_layer(self)
## @ingroup Lottie
class NullLayer(Layer):
"""!
Layer with no data, useful to group layers together
"""
## %Layer type.
type = 3
def __init__(self):
Layer.__init__(self)
## @ingroup Lottie
class TextLayer(Layer):
_props = [
LottieProp("data", "t", TextAnimatorData, False),
]
## %Layer type.
type = 5
def __init__(self):
Layer.__init__(self)
## Text Data
self.data = TextAnimatorData()
## @ingroup Lottie
class ShapeLayer(Layer):
"""!
Layer containing ShapeElement objects
"""
_props = [
LottieProp("shapes", "shapes", ShapeElement, True),
]
## %Layer type.
type = 4
def __init__(self):
Layer.__init__(self)
## Shape list of items
self.shapes = [] # ShapeElement
def add_shape(self, shape):
self.shapes.append(shape)
return shape
def insert_shape(self, index, shape):
self.shapes.insert(index, shape)
return shape
## @ingroup Lottie
## @todo SIF I/O
class ImageLayer(Layer):
_props = [
LottieProp("image_id", "refId", str, False),
]
## %Layer type.
type = 2
def __init__(self, image_id=""):
Layer.__init__(self)
## id pointing to the source image defined on 'assets' object
self.image_id = image_id
## @ingroup Lottie
class PreCompLayer(Layer):
_props = [
LottieProp("reference_id", "refId", str, False),
LottieProp("time_remapping", "tm", Value, False),
LottieProp("width", "w", int, False),
LottieProp("height", "h", int, False),
]
## %Layer type.
type = 0
def __init__(self, reference_id=""):
Layer.__init__(self)
## id pointing to the source composition defined on 'assets' object
self.reference_id = reference_id
## Comp's Time remapping
self.time_remapping = None
## Width
self.width = 512
## Height
self.height = 512
## @ingroup Lottie
class SolidColorLayer(Layer):
"""!
Layer with a solid color rectangle
"""
_props = [
LottieProp("color", "sc", str, False),
LottieProp("height", "sh", float, False),
LottieProp("width", "sw", float, False),
]
## %Layer type.
type = 1
def __init__(self, color="", width=512, height=512):
Layer.__init__(self)
## Color of the layer as a @c \#rrggbb hex
# @todo Convert NVector to string
self.color = color
## Height of the layer.
self.height = height
## Width of the layer.
self.width = width
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+148
View File
@@ -0,0 +1,148 @@
import operator
import math
def vop(op, a, b):
return list(map(op, a, b))
class NVector():
def __init__(self, *components):
self.components = list(components)
def __str__(self):
return str(self.components)
def __repr__(self):
return "<NVector %s>" % self
def __len__(self):
return len(self.components)
def to_list(self):
return list(self.components)
def __add__(self, other):
return type(self)(*vop(operator.add, self.components, other.components))
def __sub__(self, other):
return type(self)(*vop(operator.sub, self.components, other.components))
def __mul__(self, scalar):
if isinstance(scalar, NVector):
return type(self)(*vop(operator.mul, self.components, scalar.components))
return type(self)(*(c * scalar for c in self.components))
def __truediv__(self, scalar):
return type(self)(*(c / scalar for c in self.components))
def __iadd__(self, other):
self.components = vop(operator.add, self.components, other.components)
return self
def __isub__(self, other):
self.components = vop(operator.sub, self.components, other.components)
return self
def __imul__(self, scalar):
if isinstance(scalar, NVector):
self.components = vop(operator.mul, self.components, scalar.components)
else:
self.components = [c * scalar for c in self.components]
return self
def __itruediv__(self, scalar):
self.components = [c / scalar for c in self.components]
return self
def __neg__(self):
return type(self)(*(-c for c in self.components))
def __getitem__(self, key):
if isinstance(key, slice):
return NVector(*self.components[key])
return self.components[key]
def __setitem__(self, key, value):
self.components[key] = value
def __eq__(self, other):
return self.components == other.components
def __abs__(self):
return type(self)(*(abs(c) for c in self.components))
@property
def length(self):
return math.sqrt(sum(map(lambda x: x**2, self.components)))
def dot(self, other):
return sum(map(operator.mul, self.components, other.components))
def clone(self):
return NVector(*self.components)
def lerp(self, other, t):
return self * (1-t) + other * t
@property
def x(self):
return self.components[0]
@x.setter
def x(self, v):
self.components[0] = v
@property
def y(self):
return self.components[1]
@y.setter
def y(self, v):
self.components[1] = v
@property
def z(self):
return self.components[2]
@z.setter
def z(self, v):
self.components[2] = v
def element_scaled(self, other):
return type(self)(*vop(operator.mul, self.components, other.components))
def cross(self, other):
"""
@pre len(self) == len(other) == 3
"""
a = self
b = other
return type(self)(
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
)
@property
def polar_angle(self):
"""
@pre len(self) == 2
"""
return math.atan2(self.y, self.x)
def Point(x, y):
return NVector(x, y)
def Size(x, y):
return NVector(x, y)
def Point3D(x, y, z):
return NVector(x, y, z)
def PolarVector(length, theta):
return NVector(length * math.cos(theta), length * math.sin(theta))
+686
View File
@@ -0,0 +1,686 @@
import math
from functools import reduce
from .base import LottieObject, LottieProp, PseudoList, PseudoBool
from .easing import KeyframeBezierHandle, Linear
from .nvector import NVector
from .bezier import Bezier
from .color import Color
class KeyframeBezier:
NEWTON_ITERATIONS = 4
NEWTON_MIN_SLOPE = 0.001
SUBDIVISION_PRECISION = 0.0000001
SUBDIVISION_MAX_ITERATIONS = 10
SPLINE_TABLE_SIZE = 11
SAMPLE_STEP_SIZE = 1.0 / (SPLINE_TABLE_SIZE - 1.0)
def __init__(self, h1, h2):
self.h1 = h1
self.h2 = h2
self._sample_values = None
@classmethod
def from_keyframe(cls, keyframe):
return cls(keyframe.out_value, keyframe.in_value)
def bezier(self):
bez = Bezier()
bez.add_point(NVector(0, 0), outp=NVector(self.h1.x, self.h1.y))
bez.add_point(NVector(1, 1), inp=NVector(self.h2.x-1, self.h2.y-1))
return bez
def _a(self, c1, c2):
return 1 - 3 * c2 + 3 * c1
def _b(self, c1, c2):
return 3 * c2 - 6 * c1
def _c(self, c1):
return 3 * c1
def _bezier_component(self, t, c1, c2):
return ((self._a(c1, c2) * t + self._b(c1, c2)) * t + self._c(c1)) * t
def point_at(self, t):
return NVector(
self._bezier_component(t, self.h1.x, self.h2.x),
self._bezier_component(t, self.h1.y, self.h2.y)
)
def _slope_component(self, t, c1, c2):
return 3 * self._a(c1, c2) * t * t + 2 * self._b(c1, c2) * t + self._c(c1)
def slope_at(self, t):
return NVector(
self._slope_component(t, self.h1.x, self.h2.x),
self._slope_component(t, self.h1.y, self.h2.y)
)
def _binary_subdivide(self, x, interval_start, interval_end):
current_x = None
t = None
i = 0
for i in range(self.SUBDIVISION_MAX_ITERATIONS):
if current_x is not None and abs(current_x) < self.SUBDIVISION_PRECISION:
break
t = interval_start + (interval_end - interval_start) / 2.0
current_x = self._bezier_component(t, self.h1.x, self.h2.x) - x
if current_x > 0.0:
interval_end = t
else:
interval_start = t
return t
def _newton_raphson(self, x, t_guess):
for i in range(self.NEWTON_ITERATIONS):
slope = self._slope_component(t_guess, self.h1.x, self.h2.x)
if slope == 0:
return t_guess
current_x = self._bezier_component(t_guess, self.h1.x, self.h2.x) - x
t_guess -= current_x / slope
return t_guess
def _get_sample_values(self):
if self._sample_values is None:
self._sample_values = [
self._bezier_component(i * self.SAMPLE_STEP_SIZE, self.h1.x, self.h2.x)
for i in range(self.SPLINE_TABLE_SIZE)
]
return self._sample_values
def t_for_x(self, x):
sample_values = self._get_sample_values()
interval_start = 0
current_sample = 1
last_sample = self.SPLINE_TABLE_SIZE - 1
while current_sample != last_sample and sample_values[current_sample] <= x:
interval_start += self.SAMPLE_STEP_SIZE
current_sample += 1
current_sample -= 1
dist = (x - sample_values[current_sample]) / (sample_values[current_sample+1] - sample_values[current_sample])
t_guess = interval_start + dist * self.SAMPLE_STEP_SIZE
initial_slope = self._slope_component(t_guess, self.h1.x, self.h2.x)
if initial_slope >= self.NEWTON_MIN_SLOPE:
return self._newton_raphson(x, t_guess)
if initial_slope == 0:
return t_guess
return self._binary_subdivide(x, interval_start, interval_start + self.SAMPLE_STEP_SIZE)
def y_at_x(self, x):
t = self.t_for_x(x)
return self._bezier_component(t, self.h1.y, self.h2.y)
## @ingroup Lottie
class Keyframe(LottieObject):
_props = [
LottieProp("time", "t", float, False),
LottieProp("in_value", "i", KeyframeBezierHandle, False),
LottieProp("out_value", "o", KeyframeBezierHandle, False),
LottieProp("jump", "h", PseudoBool),
]
def __init__(self, time=0, easing_function=None):
"""!
@param time Start time of keyframe segment
@param easing_function Callable that performs the easing
"""
## Start time of keyframe segment.
self.time = time
## Bezier curve easing in value.
self.in_value = None
## Bezier curve easing out value.
self.out_value = None
## Jump to the end value
self.jump = None
if easing_function:
easing_function(self)
def bezier(self):
if self.jump:
bez = Bezier()
bez.add_point(NVector(0, 0))
bez.add_point(NVector(1, 0))
bez.add_point(NVector(1, 1))
return bez
else:
return KeyframeBezier.from_keyframe(self).bezier()
def lerp_factor(self, ratio):
return KeyframeBezier.from_keyframe(self).y_at_x(ratio)
def __str__(self):
return "%s %s" % (self.time, self.start)
## @ingroup Lottie
class OffsetKeyframe(Keyframe):
"""!
Keyframe for MultiDimensional values
@par Bezier easing
@parblock
Imagine a quadratic bezier, with starting point at (0, 0) and end point at (1, 1).
@p out_value and @p in_value are the other two handles for a quadratic bezier,
expressed as absoulte values in this 0-1 space.
See also https://cubic-bezier.com/
@endparblock
"""
_props = [
LottieProp("start", "s", NVector, False),
LottieProp("end", "e", NVector, False),
LottieProp("in_tan", "ti", NVector, False),
LottieProp("out_tan", "to", NVector, False),
]
def __init__(self, time=0, start=None, end=None, easing_function=None, in_tan=None, out_tan=None):
Keyframe.__init__(self, time, easing_function)
## Start value of keyframe segment.
self.start = start
## End value of keyframe segment.
self.end = end
## In Spatial Tangent. Only for spatial properties. (for bezier smoothing on position)
self.in_tan = in_tan
## Out Spatial Tangent. Only for spatial properties. (for bezier smoothing on position)
self.out_tan = out_tan
def interpolated_value(self, ratio, next_start=None):
end = next_start if self.end is None else self.end
if end is None:
return self.start
if not self.in_value or not self.out_value:
return self.start
if ratio == 1:
return end
if ratio == 0:
return self.start
if self.in_tan and self.out_tan:
bezier = Bezier()
bezier.add_point(self.start, NVector(0, 0), self.out_tan)
bezier.add_point(end, self.in_tan, NVector(0, 0))
return bezier.point_at(ratio)
lerpv = self.lerp_factor(ratio)
return self.start.lerp(end, lerpv)
def interpolated_tangent_angle(self, ratio, next_start=None):
end = next_start if self.end is None else self.end
if end is None or not self.in_tan or not self.out_tan:
return 0
bezier = Bezier()
bezier.add_point(self.start, NVector(0, 0), self.out_tan)
bezier.add_point(end, self.in_tan, NVector(0, 0))
return bezier.tangent_angle_at(ratio)
def __repr__(self):
return "<%s.%s %s %s%s>" % (
type(self).__module__,
type(self).__name__,
self.time,
self.start,
(" -> %s" % self.end) if self.end is not None else ""
)
class AnimatableMixin:
keyframe_type = Keyframe
def __init__(self, value=None):
## Non-animated value
self.value = value
## Property index
self.property_index = None
## Whether it's animated
self.animated = False
## Keyframe list
self.keyframes = None
def clear_animation(self, value):
"""!
Sets a fixed value, removing animated keyframes
"""
self.value = value
self.animated = False
self.keyframes = None
def add_keyframe(self, time, value, interp=Linear(), *args, **kwargs):
"""!
@param time The time this keyframe appears in
@param value The value the property should have at @p time
@param interp The easing callable used to update the tangents of the previous keyframe
@param args Extra arguments to pass the keyframe constructor
@param kwargs Extra arguments to pass the keyframe constructor
@note Always call add_keyframe with increasing @p time value
"""
if not self.animated:
self.value = None
self.keyframes = []
self.animated = True
else:
if self.keyframes[-1].time == time:
if value != self.keyframes[-1].start:
self.keyframes[-1].start = value
return
else:
self.keyframes[-1].end = value.clone()
self.keyframes.append(self.keyframe_type(
time,
value,
None,
interp,
*args,
**kwargs
))
def get_value(self, time=0):
"""!
@brief Returns the value of the property at the given frame/time
"""
if not self.animated:
return self.value
if not self.keyframes:
return None
return self._get_value_helper(time)[0]
def _get_value_helper(self, time):
val = self.keyframes[0].start
for i in range(len(self.keyframes)):
k = self.keyframes[i]
if time - k.time <= 0:
if k.start is not None:
val = k.start
kp = self.keyframes[i-1] if i > 0 else None
if kp:
t = (time - kp.time) / (k.time - kp.time)
end = kp.end
if end is None:
end = val
if end is not None:
val = kp.interpolated_value(t, end)
return val, end, kp, t
return val, None, None, None
if k.end is not None:
val = k.end
return val, None, None, None
def to_dict(self):
d = super().to_dict()
if self.animated:
if "k" not in d:
return d
last = d["k"][-1]
last.pop("i", None)
last.pop("o", None)
return d
def __repr__(self):
if self.keyframes and len(self.keyframes) > 1:
val = "%s -> %s" % (self.keyframes[0].start, self.keyframes[-2].end)
else:
val = self.value
return "<%s.%s %s>" % (type(self).__module__, type(self).__name__, val)
def __str__(self):
if self.animated:
return "animated"
return str(self.value)
@classmethod
def merge_keyframes(cls, items, conversion):
"""
@todo Remove similar functionality from SVG/sif parsers
"""
keyframes = []
for animatable in items:
if animatable.animated:
keyframes.extend(animatable.keyframes)
# TODO properly interpolate tangents
new_kframes = []
for keyframe in sorted(keyframes, key=lambda kf: kf.time):
if new_kframes and new_kframes[-1].time == keyframe.time:
continue
kfcopy = keyframe.clone()
kfcopy.start = conversion(*(i.get_value(keyframe.time) for i in items))
new_kframes.append(kfcopy)
for i in range(0, len(new_kframes) - 1):
new_kframes[i].end = new_kframes[i+1].start
return new_kframes
@classmethod
def load(cls, lottiedict):
obj = super().load(lottiedict)
if "a" not in lottiedict:
obj.animated = prop_animated(lottiedict)
return obj
def prop_animated(l):
if "a" in l:
return l["a"]
if "k" not in l:
return False
if isinstance(l["k"], list) and l["k"] and isinstance(l["k"][0], dict):
return True
return False
def prop_not_animated(l):
return not prop_animated(l)
## @ingroup Lottie
class MultiDimensional(AnimatableMixin, LottieObject):
"""!
An animatable property that holds a NVector
"""
keyframe_type = OffsetKeyframe
_props = [
LottieProp("value", "k", NVector, False, prop_not_animated),
LottieProp("property_index", "ix", int, False),
LottieProp("animated", "a", PseudoBool, False),
LottieProp("keyframes", "k", OffsetKeyframe, True, prop_animated),
]
def get_tangent_angle(self, time=0):
"""!
@brief Returns the value tangent angle of the property at the given frame/time
"""
if not self.keyframes or len(self.keyframes) < 2:
return 0
val, end, kp, t = self._get_value_helper(time)
if kp:
return kp.interpolated_tangent_angle(t, end)
if self.keyframes[0].time >= time:
end = self.keyframes[0].end if self.keyframes[0].end is not None else self.keyframes[1].start
return self.keyframes[0].interpolated_tangent_angle(0, end)
return 0
class PositionValue(MultiDimensional):
_props = [
LottieProp("value", "k", NVector, False, prop_not_animated),
LottieProp("property_index", "ix", int, False),
LottieProp("animated", "a", PseudoBool, False),
LottieProp("keyframes", "k", OffsetKeyframe, True, prop_animated),
]
@classmethod
def load(cls, lottiedict):
obj = super().load(lottiedict)
if lottiedict.get("s", False):
cls._load_split(lottiedict, obj)
return obj
@classmethod
def _load_split(cls, lottiedict, obj):
components = [
Value.load(lottiedict.get("x", {})),
Value.load(lottiedict.get("y", {})),
]
if "z" in lottiedict:
components.append(Value.load(lottiedict.get("z", {})))
has_anim = any(x for x in components if x.animated)
if not has_anim:
obj.value = NVector(*(a.value for a in components))
obj.animated = False
obj.keyframes = None
return
obj.animated = True
obj.value = None
obj.keyframes = cls.merge_keyframes(components, NVector)
class ColorValue(AnimatableMixin, LottieObject):
"""!
An animatable property that holds a Color
"""
keyframe_type = OffsetKeyframe
_props = [
LottieProp("value", "k", Color, False, prop_not_animated),
LottieProp("property_index", "ix", int, False),
LottieProp("animated", "a", PseudoBool, False),
LottieProp("keyframes", "k", OffsetKeyframe, True, prop_animated),
]
## @ingroup Lottie
class GradientColors(LottieObject):
"""!
Represents colors and offsets in a gradient
Colors are represented as a flat list interleaving offsets and color components in weird ways
There are two possible layouts:
Without alpha, the colors are a sequence of offset, r, g, b
With alpha, same as above but at the end of the list there is a sequence of offset, alpha
Examples:
For the gradient [0, red], [0.5, yellow], [1, green]
The list would be [0, 1, 0, 0, 0.5, 1, 1, 0, 1, 0, 1, 0]
For the gradient [0, red at 80% opacity], [0.5, yellow at 70% opacity], [1, green at 60% opacity]
The list would be [0, 1, 0, 0, 0.5, 1, 1, 0, 1, 0, 1, 0, 0, 0.8, 0.5, 0.7, 1, 0.6]
"""
_props = [
LottieProp("colors", "k", MultiDimensional),
LottieProp("count", "p", int),
]
def __init__(self, stops=[]):
## Animatable colors, as a vector containing [offset, r, g, b] values as a flat array
self.colors = MultiDimensional(NVector())
## Number of colors
self.count = 0
if stops:
self.set_stops(stops)
@staticmethod
def color_to_stops(self, colors):
"""
Converts a list of colors (Color) to tuples (offset, color)
"""
return [
(i / (len(colors)-1), color)
for i, color in enumerate(colors)
]
def set_stops(self, stops, keyframe=None):
"""!
@param stops iterable of (offset, Color) tuples
@param keyframe keyframe index (or None if not animated)
"""
flat = self._flatten_stops(stops)
if self.colors.animated and keyframe is not None:
if keyframe > 1:
self.colors.keyframes[keyframe-1].end = flat
self.colors.keyframes[keyframe].start = flat
else:
self.colors.clear_animation(flat)
self.count = len(stops)
def _flatten_stops(self, stops):
flattened_colors = NVector(*reduce(
lambda a, b: a + b,
(
[off] + color.components[:3]
for off, color in stops
)
))
if any(len(c) > 3 for o, c in stops):
flattened_colors.components += reduce(
lambda a, b: a + b,
(
[off] + [self._get_alpha(color)]
for off, color in stops
)
)
return flattened_colors
def _get_alpha(self, color):
if len(color) > 3:
return color[3]
return 1
def _add_to_flattened(self, offset, color, flattened):
flat = [offset] + list(color[:3])
rgb_size = 4 * self.count
if len(flattened) == rgb_size:
# No alpha
flattened.extend(flat)
if self.count == 0 and len(color) > 3:
flattened.append(offset)
flattened.append(color[3])
else:
flattened[rgb_size:rgb_size] = flat
flattened.append(offset)
flattened.append(self._get_alpha(color))
def add_color(self, offset, color, keyframe=None):
if self.colors.animated:
if keyframe is None:
for kf in self.colors.keyframes:
if kf.start:
self._add_to_flattened(offset, color, kf.start.components)
if kf.end:
self._add_to_flattened(offset, color, kf.end.components)
else:
if keyframe > 1:
self._add_to_flattened(offset, color, self.colors.keyframes[keyframe-1].end.components)
self._add_to_flattened(offset, color, self.colors.keyframes[keyframe].start.components)
else:
self._add_to_flattened(offset, color, self.colors.value.components)
self.count += 1
def add_keyframe(self, time, stops, ease=Linear()):
"""!
@param time Frame time
@param stops Iterable of (offset, Color) tuples
@param ease Easing function
"""
self.colors.add_keyframe(time, self._flatten_stops(stops), ease)
def get_stops(self, keyframe=None):
if keyframe is not None:
colors = self.colors.keyframes[keyframe].start
else:
colors = self.colors.value
return self._stops_from_flat(colors)
def _stops_from_flat(self, colors):
if len(colors) == 4 * self.count:
for i in range(self.count):
off = i * 4
yield colors[off], Color(*colors[off+1:off+4])
else:
for i in range(self.count):
off = i * 4
aoff = self.count * 4 + i * 2 + 1
yield colors[off], Color(colors[off+1], colors[off+2], colors[off+3], colors[aoff])
def stops_at(self, time):
return self._stops_from_flat(self.colors.get_value(time))
## @ingroup Lottie
class Value(AnimatableMixin, LottieObject):
"""!
An animatable property that holds a float
"""
keyframe_type = OffsetKeyframe
_props = [
LottieProp("value", "k", float, False, prop_not_animated),
LottieProp("property_index", "ix", int, False),
LottieProp("animated", "a", PseudoBool, False),
LottieProp("keyframes", "k", keyframe_type, True, prop_animated),
]
def __init__(self, value=0):
super().__init__(value)
def add_keyframe(self, time, value, ease=Linear()):
super().add_keyframe(time, NVector(value), ease)
def get_value(self, time=0):
v = super().get_value(time)
if self.animated and self.keyframes:
return v[0]
return v
## @ingroup Lottie
class ShapePropKeyframe(Keyframe):
"""!
Keyframe holding Bezier objects
"""
_props = [
LottieProp("start", "s", Bezier, PseudoList),
LottieProp("end", "e", Bezier, PseudoList),
]
def __init__(self, time=0, start=None, end=None, easing_function=None):
Keyframe.__init__(self, time, easing_function)
## Start value of keyframe segment.
self.start = start
## End value of keyframe segment.
self.end = end
def interpolated_value(self, ratio, next_start=None):
end = next_start if self.end is None else self.end
if end is None:
return self.start
if not self.in_value or not self.out_value:
return self.start
if ratio == 1:
return end
if ratio == 0 or len(self.start.vertices) != len(end.vertices):
return self.start
lerpv = self.lerp_factor(ratio)
bez = Bezier()
bez.closed = self.start.closed
for i in range(len(self.start.vertices)):
bez.vertices.append(self.start.vertices[i].lerp(end.vertices[i], lerpv))
bez.in_tangents.append(self.start.in_tangents[i].lerp(end.in_tangents[i], lerpv))
bez.out_tangents.append(self.start.out_tangents[i].lerp(end.out_tangents[i], lerpv))
return bez
## @ingroup Lottie
class ShapeProperty(AnimatableMixin, LottieObject):
"""!
An animatable property that holds a Bezier
"""
keyframe_type = ShapePropKeyframe
_props = [
LottieProp("value", "k", Bezier, False, prop_not_animated),
#LottieProp("expression", "x", str, False),
LottieProp("property_index", "ix", float, False),
LottieProp("animated", "a", PseudoBool, False),
LottieProp("keyframes", "k", keyframe_type, True, prop_animated),
]
def __init__(self, bezier=None):
super().__init__(bezier or Bezier())
+847
View File
@@ -0,0 +1,847 @@
import math
from .base import LottieObject, LottieProp, LottieEnum, NVector
from .properties import Value, MultiDimensional, GradientColors, ShapeProperty, Bezier, ColorValue
from .color import Color
from .helpers import Transform
class BoundingBox:
"""!
Shape bounding box
"""
def __init__(self, x1=None, y1=None, x2=None, y2=None):
self.x1 = x1
self.y1 = y1
self.x2 = x2
self.y2 = y2
def include(self, x, y):
"""!
Expands the box to include the point at x, y
"""
if x is not None:
if self.x1 is None or self.x1 > x:
self.x1 = x
if self.x2 is None or self.x2 < x:
self.x2 = x
if y is not None:
if self.y1 is None or self.y1 > y:
self.y1 = y
if self.y2 is None or self.y2 < y:
self.y2 = y
def expand(self, other):
"""!
Expands the bounding box to include another bounding box
"""
self.include(other.x1, other.y1)
self.include(other.x2, other.y2)
def center(self):
"""!
Center point of the bounding box
"""
return NVector((self.x1 + self.x2) / 2, (self.y1 + self.y2) / 2)
def isnull(self):
"""!
Whether the box is default-initialized
"""
return self.x1 is None or self.y2 is None
def __repr__(self):
return "<BoundingBox [%s, %s] - [%s, %s]>" % (self.x1, self.y1, self.x2, self.y2)
@property
def width(self):
if self.isnull():
return 0
return self.x2 - self.x1
@property
def height(self):
if self.isnull():
return 0
return self.y2 - self.y1
def size(self):
return NVector(self.width, self.height)
## @ingroup Lottie
class ShapeElement(LottieObject):
"""!
Base class for all elements of ShapeLayer and Group
"""
_props = [
#LottieProp("match_name", "mn", str, False),
LottieProp("hidden", "hd", bool, False),
LottieProp("name", "nm", str, False),
LottieProp("type", "ty", str, False),
LottieProp("property_index", "cix", int, False),
LottieProp("bm", "bm", int, False),
]
## %Shape type.
type = None
_shape_classses = None
def __init__(self):
# After Effect's Match Name. Used for expressions.
#self.match_name = ""
## After Effect's Name. Used for expressions.
self.name = None
## Property index
self.property_index = None
## Hide element
self.hidden = None
## @todo figure out?
self.bm = None
def bounding_box(self, time=0):
"""!
Bounding box of the shape element at the given time
"""
return BoundingBox()
@classmethod
def _load_get_class(cls, lottiedict):
if not ShapeElement._shape_classses:
ShapeElement._shape_classses = {}
ShapeElement._load_sub(ShapeElement._shape_classses)
return ShapeElement._shape_classses[lottiedict["ty"]]
@classmethod
def _load_sub(cls, dict):
for sc in cls.__subclasses__():
if sc.type:
dict[sc.type] = sc
sc._load_sub(dict)
def __str__(self):
return self.name or super().__str__()
## @ingroup Lottie
class Shape(ShapeElement):
"""!
Drawable shape
"""
_props = [
LottieProp("direction", "d", float, False),
]
def __init__(self):
ShapeElement.__init__(self)
## After Effect's Direction. Direction how the shape is drawn. Used for trim path for example.
self.direction = 1
def to_bezier(self):
"""!
Returns a Path corresponding to this Shape
"""
raise NotImplementedError()
## @ingroup Lottie
class Rect(Shape):
"""!
A simple rectangle shape
"""
_props = [
LottieProp("position", "p", MultiDimensional, False),
LottieProp("size", "s", MultiDimensional, False),
LottieProp("rounded", "r", Value, False),
]
## %Shape type.
type = "rc"
def __init__(self, pos=None, size=None, rounded=0):
Shape.__init__(self)
## Rect's position
self.position = MultiDimensional(pos or NVector(0, 0))
## Rect's size
self.size = MultiDimensional(size or NVector(0, 0))
## Rect's rounded corners
self.rounded = Value(rounded)
def bounding_box(self, time=0):
pos = self.position.get_value(time)
sz = self.size.get_value(time)
return BoundingBox(
pos[0] - sz[0]/2,
pos[1] - sz[1]/2,
pos[0] + sz[0]/2,
pos[1] + sz[1]/2,
)
def to_bezier(self):
"""!
Returns a Shape corresponding to this rect
"""
shape = Path()
kft = set()
if self.position.animated:
kft |= set(kf.time for kf in self.position.keyframes)
if self.size.animated:
kft |= set(kf.time for kf in self.size.keyframes)
if self.rounded.animated:
kft |= set(kf.time for kf in self.rounded.keyframes)
if not kft:
shape.shape.value = self._bezier_t(0)
else:
for time in sorted(kft):
shape.shape.add_keyframe(time, self._bezier_t(time))
return shape
def _bezier_t(self, time):
bezier = Bezier()
bb = self.bounding_box(time)
rounded = self.rounded.get_value(time)
tl = NVector(bb.x1, bb.y1)
tr = NVector(bb.x2, bb.y1)
br = NVector(bb.x2, bb.y2)
bl = NVector(bb.x1, bb.y2)
if not self.rounded.animated and rounded == 0:
bezier.add_point(tl)
bezier.add_point(tr)
bezier.add_point(br)
bezier.add_point(bl)
else:
hh = NVector(rounded/2, 0)
vh = NVector(0, rounded/2)
hd = NVector(rounded, 0)
vd = NVector(0, rounded)
bezier.add_point(tl+vd, outp=-vh)
bezier.add_point(tl+hd, -hh)
bezier.add_point(tr-hd, outp=hh)
bezier.add_point(tr+vd, -vh)
bezier.add_point(br-vd, outp=vh)
bezier.add_point(br-hd, hh)
bezier.add_point(bl+hd, outp=-hh)
bezier.add_point(bl-vd, vh)
bezier.close()
return bezier
## @ingroup Lottie
class StarType(LottieEnum):
Star = 1
Polygon = 2
## @ingroup Lottie
class Star(Shape):
"""!
Star shape
"""
_props = [
LottieProp("position", "p", MultiDimensional, False),
LottieProp("inner_radius", "ir", Value, False),
LottieProp("inner_roundness", "is", Value, False),
LottieProp("outer_radius", "or", Value, False),
LottieProp("outer_roundness", "os", Value, False),
LottieProp("rotation", "r", Value, False),
LottieProp("points", "pt", Value, False),
LottieProp("star_type", "sy", StarType, False),
]
## %Shape type.
type = "sr"
def __init__(self):
Shape.__init__(self)
## Star's position
self.position = MultiDimensional(NVector(0, 0))
## Star's inner radius. (Star only)
self.inner_radius = Value()
## Star's inner roundness. (Star only)
self.inner_roundness = Value()
## Star's outer radius.
self.outer_radius = Value()
## Star's outer roundness.
self.outer_roundness = Value()
## Star's rotation.
self.rotation = Value()
## Star's number of points.
self.points = Value(5)
## Star's type. Polygon or Star.
self.star_type = StarType.Star
def bounding_box(self, time=0):
pos = self.position.get_value(time)
r = self.outer_radius.get_value(time)
return BoundingBox(
pos[0] - r,
pos[1] - r,
pos[0] + r,
pos[1] + r,
)
def to_bezier(self):
"""!
Returns a Shape corresponding to this star
"""
shape = Path()
kft = set()
if self.position.animated:
kft |= set(kf.time for kf in self.position.keyframes)
if self.inner_radius.animated:
kft |= set(kf.time for kf in self.inner_radius.keyframes)
if self.inner_roundness.animated:
kft |= set(kf.time for kf in self.inner_roundness.keyframes)
if self.points.animated:
kft |= set(kf.time for kf in self.points.keyframes)
if self.rotation.animated:
kft |= set(kf.time for kf in self.rotation.keyframes)
# TODO inner_roundness / outer_roundness
if not kft:
shape.shape.value = self._bezier_t(0)
else:
for time in sorted(kft):
shape.shape.add_keyframe(time, self._bezier_t(time))
return shape
def _bezier_t(self, time):
bezier = Bezier()
pos = self.position.get_value(time)
r1 = self.inner_radius.get_value(time)
r2 = self.outer_radius.get_value(time)
rot = -(self.rotation.get_value(time)) * math.pi / 180 + math.pi
p = self.points.get_value(time)
halfd = -math.pi / p
for i in range(int(p)):
main_angle = rot + i * halfd * 2
dx = r2 * math.sin(main_angle)
dy = r2 * math.cos(main_angle)
bezier.add_point(NVector(pos.x + dx, pos.y + dy))
if self.star_type == StarType.Star:
dx = r1 * math.sin(main_angle+halfd)
dy = r1 * math.cos(main_angle+halfd)
bezier.add_point(NVector(pos.x + dx, pos.y + dy))
bezier.close()
return bezier
## @ingroup Lottie
class Ellipse(Shape):
"""!
Ellipse shape
"""
_props = [
LottieProp("position", "p", MultiDimensional, False),
LottieProp("size", "s", MultiDimensional, False),
]
## %Shape type.
type = "el"
def __init__(self, position=None, size=None):
Shape.__init__(self)
## Ellipse's position
self.position = MultiDimensional(position or NVector(0, 0))
## Ellipse's size
self.size = MultiDimensional(size or NVector(0, 0))
def bounding_box(self, time=0):
pos = self.position.get_value(time)
sz = self.size.get_value(time)
return BoundingBox(
pos[0] - sz[0]/2,
pos[1] - sz[1]/2,
pos[0] + sz[0]/2,
pos[1] + sz[1]/2,
)
def to_bezier(self):
"""!
Returns a Shape corresponding to this ellipse
"""
shape = Path()
kft = set()
if self.position.animated:
kft |= set(kf.time for kf in self.position.keyframes)
if self.size.animated:
kft |= set(kf.time for kf in self.size.keyframes)
if not kft:
shape.shape.value = self._bezier_t(0)
else:
for time in sorted(kft):
shape.shape.add_keyframe(time, self._bezier_t(time))
return shape
def _bezier_t(self, time):
from ..utils.ellipse import Ellipse as EllipseConverter
bezier = Bezier()
position = self.position.get_value(time)
radii = self.size.get_value(time) / 2
el = EllipseConverter(position, radii, 0)
points = el.to_bezier(0, math.pi*2)
for point in points[1:]:
bezier.add_point(point.vertex, point.in_tangent, point.out_tangent)
bezier.close()
return bezier
## @ingroup Lottie
class Path(Shape):
"""!
Animatable Bezier curve
"""
_props = [
LottieProp("shape", "ks", ShapeProperty, False),
LottieProp("index", "ind", int, False),
]
## %Shape type.
type = "sh"
def __init__(self, bezier=None):
Shape.__init__(self)
## Shape's vertices
self.shape = ShapeProperty(bezier or Bezier())
## @todo Index?
self.index = None
def bounding_box(self, time=0):
pos = self.shape.get_value(time)
bb = BoundingBox()
for v in pos.vertices:
bb.include(*v)
return bb
def to_bezier(self):
return self.clone()
## @ingroup Lottie
class Group(ShapeElement):
"""!
ShapeElement that can contain other shapes
@note Shapes inside the same group will create "holes" in other shapes
"""
_props = [
LottieProp("number_of_properties", "np", float, False),
LottieProp("shapes", "it", ShapeElement, True),
]
## %Shape type.
type = "gr"
def __init__(self):
ShapeElement.__init__(self)
## Group number of properties. Used for expressions.
self.number_of_properties = None
## Group list of items
self.shapes = [TransformShape()]
@property
def transform(self):
return self.shapes[-1]
def bounding_box(self, time=0):
bb = BoundingBox()
for v in self.shapes:
bb.expand(v.bounding_box(time))
if not bb.isnull():
mat = self.transform.to_matrix(time)
points = [
mat.apply(NVector(bb.x1, bb.y1)),
mat.apply(NVector(bb.x1, bb.y2)),
mat.apply(NVector(bb.x2, bb.y2)),
mat.apply(NVector(bb.x2, bb.y1)),
]
x1 = min(p.x for p in points)
x2 = max(p.x for p in points)
y1 = min(p.y for p in points)
y2 = max(p.y for p in points)
return BoundingBox(x1, y1, x2, y2)
return bb
def add_shape(self, shape):
self.shapes.insert(-1, shape)
return shape
def insert_shape(self, index, shape):
self.shapes.insert(index, shape)
return shape
@classmethod
def load(cls, lottiedict):
object = ShapeElement.load(lottiedict)
shapes = []
transform = None
for obj in object.shapes:
if isinstance(obj, TransformShape):
if not transform:
transform = obj
else:
shapes.append(obj)
object.shapes = shapes
object.shapes.append(transform)
return object
## @ingroup Lottie
class FillRule(LottieEnum):
NonZero = 1
EvenOdd = 2
## @ingroup Lottie
class Fill(ShapeElement):
"""!
Solid fill color
"""
_props = [
LottieProp("opacity", "o", Value, False),
LottieProp("color", "c", ColorValue, False),
LottieProp("fill_rule", "r", FillRule, False),
]
## %Shape type.
type = "fl"
def __init__(self, color=None):
ShapeElement.__init__(self)
## Fill Opacity
self.opacity = Value(100)
## Fill Color
self.color = ColorValue(color or Color(1, 1, 1))
## Fill rule
self.fill_rule = None
## @ingroup Lottie
class GradientType(LottieEnum):
Linear = 1
Radial = 2
## @ingroup Lottie
class Gradient(LottieObject):
_props = [
LottieProp("start_point", "s", MultiDimensional, False),
LottieProp("end_point", "e", MultiDimensional, False),
LottieProp("gradient_type", "t", GradientType, False),
LottieProp("highlight_length", "h", Value, False),
LottieProp("highlight_angle", "a", Value, False),
LottieProp("colors", "g", GradientColors, False),
]
def __init__(self, colors=[]):
## Fill Opacity
self.opacity = Value(100)
## Gradient Start Point
self.start_point = MultiDimensional(NVector(0, 0))
## Gradient End Point
self.end_point = MultiDimensional(NVector(0, 0))
## Gradient Type
self.gradient_type = GradientType.Linear
## Gradient Highlight Length. Only if type is Radial
self.highlight_length = Value()
## Highlight Angle. Only if type is Radial
self.highlight_angle = Value()
## Gradient Colors
self.colors = GradientColors(colors)
## @ingroup Lottie
class GradientFill(ShapeElement, Gradient):
"""!
Gradient fill
"""
_props = [
LottieProp("opacity", "o", Value, False),
LottieProp("fill_rule", "r", FillRule, False),
]
## %Shape type.
type = "gf"
def __init__(self, colors=[]):
ShapeElement.__init__(self)
Gradient.__init__(self, colors)
## Fill Opacity
self.opacity = Value(100)
## Fill rule
self.fill_rule = None
## @ingroup Lottie
class LineJoin(LottieEnum):
Miter = 1
Round = 2
Bevel = 3
## @ingroup Lottie
class LineCap(LottieEnum):
Butt = 1
Round = 2
Square = 3
## @ingroup Lottie
class StrokeDashType(LottieEnum):
Dash = "d"
Gap = "g"
Offset = "o"
## @ingroup Lottie
class StrokeDash(LottieObject):
_props = [
LottieProp("name", "nm", str, False),
LottieProp("type", "n", StrokeDashType, False),
LottieProp("length", "v", Value, False),
]
def __init__(self, length=0, type=StrokeDashType.Dash):
self.name = type.name.lower()
self.type = type
self.length = Value(length)
def __str__(self):
return self.name or super().__str__()
## @ingroup Lottie
class BaseStroke(LottieObject):
_props = [
LottieProp("line_cap", "lc", LineCap, False),
LottieProp("line_join", "lj", LineJoin, False),
LottieProp("miter_limit", "ml", float, False),
LottieProp("opacity", "o", Value, False),
LottieProp("width", "w", Value, False),
LottieProp("dashes", "d", StrokeDash, True),
]
def __init__(self, width=1):
## Stroke Line Cap
self.line_cap = LineCap.Round
## Stroke Line Join
self.line_join = LineJoin.Round
## Stroke Miter Limit. Only if Line Join is set to Miter.
self.miter_limit = 0
## Stroke Opacity
self.opacity = Value(100)
## Stroke Width
self.width = Value(width)
## Dashes
self.dashes = None
## @ingroup Lottie
class Stroke(ShapeElement, BaseStroke):
"""!
Solid stroke
"""
_props = [
LottieProp("color", "c", MultiDimensional, False),
]
## %Shape type.
type = "st"
def __init__(self, color=None, width=1):
ShapeElement.__init__(self)
BaseStroke.__init__(self, width)
## Stroke Color
self.color = ColorValue(color or Color(0, 0, 0))
## @ingroup Lottie
class GradientStroke(ShapeElement, BaseStroke, Gradient):
"""!
Gradient stroke
"""
## %Shape type.
type = "gs"
def __init__(self, stroke_width=1):
ShapeElement.__init__(self)
BaseStroke.__init__(self, stroke_width)
Gradient.__init__(self)
def bounding_box(self, time=0):
return BoundingBox()
## @ingroup Lottie
class TransformShape(ShapeElement, Transform):
"""!
Group transform
"""
## %Shape type.
type = "tr"
def __init__(self):
ShapeElement.__init__(self)
Transform.__init__(self)
self.anchor_point = MultiDimensional(NVector(0, 0))
## @ingroup Lottie
class Composite(LottieEnum):
Above = 1
Below = 2
## @ingroup Lottie
class RepeaterTransform(Transform):
_props = [
LottieProp("start_opacity", "so", Value, False),
LottieProp("end_opacity", "eo", Value, False),
]
def __init__(self):
Transform.__init__(self)
self.start_opacity = Value(100)
self.end_opacity = Value(100)
## @ingroup Lottie
class Modifier(ShapeElement):
pass
## @ingroup Lottie
class TrimMultipleShapes(LottieEnum):
Simultaneously = 1
Individually = 2
## @ingroup Lottie
## @todo Implement SIF Export
class Trim(Modifier):
"""
Trims shapes into a segment
"""
_props = [
LottieProp("start", "s", Value, False),
LottieProp("end", "e", Value, False),
LottieProp("offset", "o", Value, False),
LottieProp("multiple", "m", TrimMultipleShapes, False),
]
## %Shape type.
type = "tm"
def __init__(self):
ShapeElement.__init__(self)
## Start of the segment, as a percentage
self.start = Value(0)
## End of the segment, as a percentage
self.end = Value(100)
## start/end offset, as an angle (0, 360)
self.offset = Value(0)
## @todo?
self.multiple = None
## @ingroup Lottie
class Repeater(Modifier):
"""
Duplicates previous shapes in a group
"""
_props = [
LottieProp("copies", "c", Value, False),
LottieProp("offset", "o", Value, False),
LottieProp("composite", "m", Composite, False),
LottieProp("transform", "tr", RepeaterTransform, False),
]
## %Shape type.
type = "rp"
def __init__(self, copies=1):
Modifier.__init__(self)
## Number of Copies
self.copies = Value(copies)
## Offset of Copies
self.offset = Value()
## Composite of copies
self.composite = Composite.Above
## Transform values for each repeater copy
self.transform = RepeaterTransform()
## @ingroup Lottie
## @todo Implement SIF Export
class RoundedCorners(Modifier):
"""
Rounds corners of other shapes
"""
_props = [
LottieProp("radius", "r", Value, False),
]
## %Shape type.
type = "rd"
def __init__(self):
Modifier.__init__(self)
## Rounded Corner Radius
self.radius = Value()
## @ingroup Lottie
## @ingroup LottieCheck
## @note marked as unsupported by lottie
class Merge(ShapeElement):
_props = [
LottieProp("merge_mode", "mm", float, False),
]
## %Shape type.
type = "mm"
def __init__(self):
ShapeElement.__init__(self)
## Merge Mode
self.merge_mode = 1
## @ingroup Lottie
## @note marked as unsupported by lottie
class Twist(ShapeElement):
_props = [
LottieProp("angle", "a", Value, False),
LottieProp("center", "c", MultiDimensional, False),
]
## %Shape type.
type = "tw"
def __init__(self):
ShapeElement.__init__(self)
self.angle = Value(0)
self.center = MultiDimensional(NVector(0, 0))
class ZigZag(ShapeElement):
"""
Zig Zag shape modifier
"""
_props = [
LottieProp("frequency", "r", Value, False),
LottieProp("amplitude", "s", Value, False),
LottieProp("point_type", "pt", Value, False),
]
## %Shape type.
type = "zz"
def __init__(self):
ShapeElement.__init__(self)
## Number of ridges per segment
self.frequency = Value(5)
## Distance between peaks and troughs
self.amplitude = Value(10)
## Point type (1 = corner, 2 = smooth)
self.point_type = Value(1)
+211
View File
@@ -0,0 +1,211 @@
from .base import LottieObject, LottieProp, LottieEnum
from .properties import Value, MultiDimensional
from .nvector import NVector
from .helpers import Transform
## @ingroup Lottie
## @ingroup LottieCheck
class MaskedPath(LottieObject):
_props = [
LottieProp("mask", "m", float),
LottieProp("f", "f", Value),
LottieProp("l", "l", Value),
LottieProp("r", "r", float),
]
def __init__(self):
## Type?
self.mask = None
## First?
self.f = None
## Last?
self.l = None
## ??
self.r = None
## @ingroup Lottie
## @ingroup LottieCheck
class TextAnimatorDataProperty(Transform):
_props = [
LottieProp("rx", "rx", Value),
LottieProp("ry", "ry", Value),
LottieProp("stroke_width", "sw", Value),
LottieProp("stroke_color", "sc", MultiDimensional),
LottieProp("fill_color", "fc", MultiDimensional),
LottieProp("fh", "fh", Value),
LottieProp("fs", "fs", Value),
LottieProp("fb", "fb", Value),
LottieProp("tracking", "t", Value),
LottieProp("scale", "s", MultiDimensional),
]
def __init__(self):
super().__init__()
## Angle?
self.rx = Value()
## Angle?
self.ry = Value()
## Stroke width
self.stroke_width = Value()
## Stroke color
self.stroke_color = MultiDimensional()
## Fill color
self.fill_color = MultiDimensional()
self.fh = Value()
## 0-100?
self.fs = Value()
## 0-100?
self.fb = Value()
## Tracking
self.tracking = Value()
## @ingroup Lottie
## @ingroup LottieCheck
class TextMoreOptions(LottieObject):
_props = [
LottieProp("alignment", "a", MultiDimensional),
LottieProp("g", "g", float),
]
def __init__(self):
self.alignment = MultiDimensional(NVector(0, 0))
self.g = None
## @ingroup Lottie
class TextJustify(LottieEnum):
Left = 0
Right = 1
Center = 2
## @ingroup Lottie
class TextDocument(LottieObject):
"""!
@see http://docs.aenhancers.com/other/textdocument/
Note that for multi-line text, lines are separated by \\r
"""
_props = [
LottieProp("font_family", "f", str),
LottieProp("color", "fc", NVector),
LottieProp("font_size", "s", float),
LottieProp("line_height", "lh", float),
LottieProp("wrap_size", "sz", NVector),
LottieProp("text", "t", str),
LottieProp("justify", "j", TextJustify),
# ls?
]
def __init__(self, text="", font_size=10, color=None, font_family=""):
self.font_family = font_family
## Text color
self.color = color or NVector(0, 0, 0)
## Line height when wrapping
self.line_height = None
## Text alignment
self.justify = TextJustify.Left
## Size of the box containing the text
self.wrap_size = None
## Text
self.text = text
## Font Size
self.font_size = font_size
## @ingroup Lottie
class TextDataKeyframe(LottieObject):
_props = [
LottieProp("start", "s", TextDocument),
LottieProp("time", "t", float),
]
def __init__(self, time=0, start=None):
## Start value of keyframe segment.
self.start = start
## Start time of keyframe segment.
self.time = time
## @ingroup Lottie
class TextData(LottieObject):
_props = [
LottieProp("keyframes", "k", TextDataKeyframe, True),
]
def __init__(self):
self.keyframes = []
def get_value(self, time):
for kf in self.keyframes:
if kf.time >= time:
return kf.start
return None
## @ingroup Lottie
class TextAnimatorData(LottieObject):
_props = [
LottieProp("properties", "a", TextAnimatorDataProperty, True),
LottieProp("data", "d", TextData, False),
LottieProp("more_options", "m", TextMoreOptions, False),
LottieProp("masked_path", "p", MaskedPath),
]
def __init__(self):
self.properties = []
self.data = TextData()
self.more_options = TextMoreOptions()
self.masked_path = MaskedPath()
def add_keyframe(self, time, item):
self.data.keyframes.append(TextDataKeyframe(time, item))
def get_value(self, time):
return self.data.get_value(time)
## @ingroup Lottie
class FontPathOrigin(LottieEnum):
Unknown = 0
CssUrl = 1
ScriptUrl = 2
FontUrl = 3
## @ingroup Lottie
class Font(LottieObject):
_props = [
LottieProp("ascent", "ascent", float),
LottieProp("font_family", "fFamily", str),
LottieProp("name", "fName", str),
LottieProp("font_style", "fStyle", str),
LottieProp("path", "fPath", str),
LottieProp("weight", "fWeight", str),
LottieProp("origin", "origin", FontPathOrigin),
]
def __init__(self, font_family="sans", font_style="Regular", name=None):
self.ascent = None
self.font_family = font_family
self.font_style = font_style
self.name = name or "%s-%s" % (font_family, font_style)
self.path = None
self.weight = None
self.origin = None
## @ingroup Lottie
class FontList(LottieObject):
_props = [
LottieProp("list", "list", Font, True),
]
def __init__(self):
self.list = []
def append(self, font):
self.list.append(font)
+2
View File
@@ -0,0 +1,2 @@
from . import svg, tgs, sif
__all__ = ["svg", "tgs", "sif"]
+140
View File
@@ -0,0 +1,140 @@
import sys
import os
import pkgutil
import argparse
import importlib
class Baseporter:
def __init__(self, name, extensions, callback, extra_options=[], generic_options=set(), slug=None):
self.name = name
self.extensions = extensions
self.callback = callback
self.extra_options = extra_options
self.generic_options = generic_options
self.slug = slug if slug is not None else extensions[0]
def process(self, *a, **kw):
return self.callback(*a, **kw)
def __repr__(self):
return "<%s %s>" % (self.__class__.__name__, self.slug)
def argparse_options(self, ns):
o_options = {}
for opt in self.extra_options:
o_options[opt.dest] = getattr(ns, opt.nsvar(self.slug))
for opt in self.generic_options:
o_options[opt] = getattr(ns, opt)
return o_options
class ExtraOption:
def __init__(self, name, **kwargs):
self.name = name
self.kwargs = kwargs
if "action" not in self.kwargs:
self.kwargs["metavar"] = self.name
self.dest = kwargs.pop("dest", name)
def add_argument(self, slug, parser):
opt = "--%s-%s" % (slug, self.name.replace("_", "-"))
parser.add_argument(opt, dest=self.nsvar(slug), **self.kwargs)
def nsvar(self, slug):
return "%s_%s" % (slug, self.dest)
def _add_options(parser, ie, object):
if not object.extra_options:
return
suf = " %sing options" % ie
group = parser.add_argument_group(object.name + suf)
for op in object.extra_options:
op.add_argument(object.slug, group)
class Loader:
def __init__(self, module_path, module_name, ie):
self._loaded = False
self._registry = {}
self._module_path = os.path.dirname(module_path)
self._module_name = module_name.replace(".base", "")
self._ie = ie
self._failed = {}
def load_modules(self):
self._loaded = True
for _, modname, _ in pkgutil.iter_modules([self._module_path]):
if modname == "base":
continue
full_modname = "." + modname
try:
importlib.import_module(full_modname, self._module_name)
except ImportError as e:
self._failed[modname] = e.name
@property
def failed_modules(self):
if not self._loaded:
self.load_modules()
return self._failed
@property
def items(self):
if not self._loaded:
self.load_modules()
return self._registry
def __iter__(self):
return iter(self.items.values())
def get(self, slug):
return self.items.get(slug, None)
def __getitem__(self, key):
return self.get(key)
def get_from_filename(self, filename):
return self.get_from_extension(os.path.splitext(filename)[1][1:])
def get_from_extension(self, ext):
for p in self.items.values():
if ext in p.extensions:
return p
return None
def set_options(self, parser):
for exporter in self.items.values():
_add_options(parser, self._ie, exporter)
def keys(self):
return self.items.keys()
def decorator(self, name, extensions, extra_options=[], generic_options=set(), slug=None):
def decorator(callback):
porter = Baseporter(name, extensions, callback, extra_options, generic_options, slug)
self._registry[porter.slug] = porter
return callback
return decorator
class IoProgressReporter:
def report_progress(self, title, value, total):
sys.stderr.write("\r%s %s/%s" % (title, value, total))
sys.stderr.flush()
def report_message(self, message):
sys.stderr.write("\r" + message + "\n")
sys.stderr.flush()
IoProgressReporter.instance = IoProgressReporter()
def io_progress():
return IoProgressReporter.instance
+275
View File
@@ -0,0 +1,275 @@
from PIL import Image
from .. import objects
from .. import NVector, Color
from ..utils import color
class Polygen:
def __init__(self, x, y):
self.vertices = [
NVector(x, y),
NVector(x+1, y),
NVector(x+1, y+1),
NVector(x, y+1),
]
self._has_x = False
self._has_y = False
def add_pixel_x(self, x, y):
i = self.vertices.index(NVector(x, y))
if len(self.vertices) > i and self.vertices[i+1] == NVector(x, y+1):
self._has_x = True
self.vertices.insert(i+1, NVector(x+1, y))
self.vertices.insert(i+2, NVector(x+1, y+1))
else:
raise ValueError()
def add_pixel_x_neg(self, x, y):
i = self.vertices.index(NVector(x+1, y))
if i > 0 and self.vertices[i-1] == NVector(x+1, y+1):
self._has_x = True
self.vertices.insert(i, NVector(x, y))
self.vertices.insert(i, NVector(x, y+1))
else:
raise ValueError()
def add_pixel_y(self, x, y):
i = self.vertices.index(NVector(x, y))
if i > 0 and self.vertices[i-1] == NVector(x+1, y):
self._has_y = True
if i > 1 and self.vertices[i-2] == NVector(x+1, y+1):
self.vertices[i-1] = NVector(x, y+1)
else:
self.vertices.insert(i, NVector(x, y+1))
self.vertices.insert(i, NVector(x+1, y+1))
else:
raise ValueError()
def _to_rect(self, id1, id2):
p1 = self.vertices[id1]
p2 = self.vertices[id2]
return objects.Rect((p1+p2)/2, p2-p1)
def to_shape(self):
if not self._has_x or not self._has_y:
return self._to_rect(0, int(len(self.vertices)/2))
bez = objects.Bezier()
bez.closed = True
for point in self.vertices:
if len(bez.vertices) > 1 and (
bez.vertices[-1].x == bez.vertices[-2].x == point.x or
bez.vertices[-1].y == bez.vertices[-2].y == point.y
):
bez.vertices[-1] = point
else:
bez.add_point(point)
if len(bez.vertices) > 2 and bez.vertices[0].x == bez.vertices[-1].x == bez.vertices[-2].x:
bez.vertices.pop()
bez.out_tangents.pop()
bez.in_tangents.pop()
return objects.Path(bez)
def pixel_add_layer_paths(animation, raster):
layer = animation.add_layer(objects.ShapeLayer())
groups = {}
processed = set()
xneg_candidates = set()
def avail(x, y):
rid = (x, y)
return not (
x < 0 or x >= raster.width or y >= raster.height or
rid in processed or raster.getpixel(rid) != colort
)
def recurse(gen, x, y, xneg):
processed.add((x, y))
if avail(x+1, y):
gen.add_pixel_x(x+1, y)
recurse(gen, x+1, y, False)
if avail(x, y+1):
gen.add_pixel_y(x, y+1)
recurse(gen, x, y+1, True)
if xneg and avail(x-1, y):
xneg_candidates.add((x-1, y))
for y in range(raster.height):
for x in range(raster.width):
pid = (x, y)
colort = raster.getpixel(pid)
if colort[-1] == 0 or pid in processed:
continue
gen = Polygen(x, y)
xneg_candidates = set()
recurse(gen, x, y, False)
xneg_candidates -= processed
while xneg_candidates:
p = next(iter(sorted(xneg_candidates, key=lambda t: (t[1], t[0]))))
gen.add_pixel_x_neg(*p)
recurse(gen, p[0], p[1], True)
processed.add(p)
xneg_candidates -= processed
g = groups.setdefault(colort, set())
g.add(gen.to_shape())
for colort, rects in groups.items():
g = layer.add_shape(objects.Group())
g.shapes = list(rects) + g.shapes
g.name = "".join("%02x" % c for c in colort)
fill = g.add_shape(objects.Fill())
fill.color.value = color.from_uint8(*colort[:3])
fill.opacity.value = colort[-1] / 255 * 100
stroke = g.add_shape(objects.Stroke(fill.color.value, 0.1))
stroke.opacity.value = fill.opacity.value
return layer
def pixel_add_layer_rects(animation, raster):
layer = animation.add_layer(objects.ShapeLayer())
last_rects = {}
groups = {}
def merge_up():
if last_rect and last_rect._start in last_rects:
yrect = last_rects[last_rect._start]
if yrect.size.value.x == last_rect.size.value.x and yrect._color == last_rect._color:
groups[last_rect._color].remove(last_rect)
yrect.position.value.y += 0.5
yrect.size.value.y += 1
rects[last_rect._start] = yrect
def group(colort):
return groups.setdefault(colort, set())
for y in range(raster.height):
rects = {}
last_color = None
last_rect = None
for x in range(raster.width):
colort = raster.getpixel((x, y))
if colort[-1] == 0:
continue
yrect = last_rects.get(x, None)
if colort == last_color:
last_rect.position.value.x += 0.5
last_rect.size.value.x += 1
elif yrect and colort == yrect._color and yrect.size.value.x == 1:
yrect.position.value.y += 0.5
yrect.size.value.y += 1
rects[x] = yrect
last_color = last_rect = colort = None
else:
merge_up()
g = group(colort)
last_rect = objects.Rect()
g.add(last_rect)
last_rect.size.value = NVector(1, 1)
last_rect.position.value = NVector(x + 0.5, y + 0.5)
rects[x] = last_rect
last_rect._start = x
last_rect._color = colort
last_color = colort
merge_up()
last_rects = rects
for colort, rects in groups.items():
g = layer.add_shape(objects.Group())
g.shapes = list(rects) + g.shapes
g.name = "".join("%02x" % c for c in colort)
fill = g.add_shape(objects.Fill())
fill.color.value = color.from_uint8(*colort[:3])
fill.opacity.value = colort[-1] / 255 * 100
stroke = g.add_shape(objects.Stroke(fill.color.value, 0.1))
stroke.opacity.value = fill.opacity.value
return layer
def _vectorizing_func(filenames, frame_delay, framerate, callback):
if not isinstance(filenames, list):
filenames = [filenames]
animation = objects.Animation(0, framerate)
nframes = 0
for filename in filenames:
raster = Image.open(filename)
if nframes == 0:
animation.width = raster.width
animation.height = raster.height
if not hasattr(raster, "is_animated"):
raster.n_frames = 1
raster.seek = lambda x: None
for frame in range(raster.n_frames):
raster.seek(frame)
new_im = Image.new("RGBA", raster.size)
new_im.paste(raster)
callback(animation, new_im, nframes + frame)
new_im.close()
nframes += raster.n_frames
animation.out_point = frame_delay * nframes
#animation._nframes = nframes
return animation
def raster_to_embedded_assets(filenames, frame_delay=1, framerate=60, embed_format=None):
"""!
@brief Loads external assets
"""
def callback(animation, raster, frame):
asset = objects.assets.Image.embedded(raster, embed_format)
animation.assets.append(asset)
layer = animation.add_layer(objects.ImageLayer(asset.id))
layer.in_point = frame * frame_delay
layer.out_point = layer.in_point + frame_delay
return _vectorizing_func(filenames, frame_delay, framerate, callback)
def raster_to_linked_assets(filenames, frame_delay=1, framerate=60):
"""!
@brief Loads external assets
"""
animation = objects.Animation(frame_delay * len(filenames), framerate)
for frame, filename in enumerate(filenames):
asset = objects.assets.Image.linked(filename)
animation.assets.append(asset)
layer = animation.add_layer(objects.ImageLayer(asset.id))
layer.in_point = frame * frame_delay
layer.out_point = layer.in_point + frame_delay
return animation
def pixel_to_animation(filenames, frame_delay=1, framerate=60):
"""!
@brief Converts pixel art to vector
"""
def callback(animation, raster, frame):
layer = pixel_add_layer_rects(animation, raster.convert("RGBA"))
layer.in_point = frame * frame_delay
layer.out_point = layer.in_point + frame_delay
return _vectorizing_func(filenames, frame_delay, framerate, callback)
def pixel_to_animation_paths(filenames, frame_delay=1, framerate=60):
"""!
@brief Converts pixel art to vector paths
Slower and yields larger files compared to pixel_to_animation,
but it produces a single shape for each area with the same color.
Mostly useful when you want to add your own animations to the loaded image
"""
def callback(animation, raster, frame):
layer = pixel_add_layer_paths(animation, raster.convert("RGBA"))
layer.in_point = frame * frame_delay
layer.out_point = layer.in_point + frame_delay
return _vectorizing_func(filenames, frame_delay, framerate, callback)
+248
View File
@@ -0,0 +1,248 @@
# NOTE: requires pillow, pypotrace>=0.2, numpy, scipy to be installed
from PIL import Image
import potrace
import numpy
import enum
from scipy.cluster.vq import kmeans
from .. import objects
from ..nvector import NVector
from .pixel import _vectorizing_func
class QuanzationMode(enum.Enum):
Nearest = 1
Exact = 2
class RasterImage:
def __init__(self, data):
self.data = data
@classmethod
def from_pil(cls, image):
return cls(numpy.array(image))
#@classmethod
#def open(cls, filename):
#return cls.from_pil(Image.open(filename))
def k_means(self, n_colors):
"""!
Returns a list of centroids
"""
colors = []
for row in range(self.data.shape[0]):
for column in range(self.data.shape[1]):
if self.get_alpha(row, column) == 255:
colors.append(self.data[row][column])
colors = numpy.array(colors, numpy.float)
return kmeans(colors, n_colors+1)[0]
def get_alpha(self, row, column):
if self.data.shape[2] >= 4:
return self.data[row][column][3]
return 255
def quantize(self, codebook, quantization_mode=QuanzationMode.Nearest):
"""!
Returns a list of tuple [color, data] where for each color in codebook
data is a bit mask for the image
You can get codebook from k_means
"""
if codebook is None or len(codebook) == 0:
return [(numpy.array([0., 0., 0., 255.]), self.mono())]
mono_data = []
for c in codebook:
mono_data.append((c, numpy.zeros(self.data.shape[:2])))
for row in range(self.data.shape[0]):
for column in range(self.data.shape[1]):
if self.get_alpha(row, column) == 255:
if quantization_mode == QuanzationMode.Nearest:
min_norm = 511 # (norm of [255, 255, 255, 255]) + 1
best = None
for color, bitmap in mono_data:
norm = numpy.linalg.norm(self.data[row][column] - color)
if norm < min_norm:
min_norm = norm
best = bitmap
if norm == 0:
break
best[row][column] = 1
else:
for color, bitmap in mono_data:
if numpy.array_equal(color, self.data[row][column]):
bitmap[row][column] = 1
break
return mono_data
def mono(self):
"""!
Returns a bit mask of opaque pixels
"""
mono_data = numpy.zeros(self.data.shape[:2])
for row in range(self.data.shape[0]):
for column in range(self.data.shape[1]):
mono_data[row][column] = int(self.data[row][column][3] == 255)
return mono_data
class Vectorizer:
def __init__(self):
self.palette = None
self.layers = {}
def _create_layer(self, animation, layer_name):
layer = animation.add_layer(objects.ShapeLayer())
if layer_name:
self.layers[layer_name] = layer
layer.name = layer_name
return layer
def prepare_layer(self, animation, layer_name=None):
layer = self._create_layer(animation, layer_name)
layer._max_verts = {}
if self.palette is None:
group = layer.add_shape(objects.Group())
group.name = "bitmap"
layer._max_verts[group.name] = 0
group.add_shape(objects.Path())
group.add_shape(objects.Fill(NVector(0, 0, 0)))
else:
for color in self.palette:
group = layer.add_shape(objects.Group())
group.name = "color_%s" % "".join("%02x" % int(c) for c in color)
layer._max_verts[group.name] = 0
fcol = color/255
fill = group.add_shape(objects.Fill(NVector(*fcol)))
if len(fcol) > 3 and fcol[3] < 1:
fill.opacity.value = fcol[3] * 100
return layer
def raster_to_layer(self, animation, raster, layer_name=None, mode=QuanzationMode.Nearest):
layer = self.prepare_layer(animation, layer_name)
mono_data = raster.quantize(self.palette, mode)
for (color, bitmap), group in zip(mono_data, layer.shapes):
self.raster_to_shapes(group, bitmap)
return layer
def raster_to_shapes(self, group, mono_data):
shapes = []
for bezier in self.raster_to_bezier(mono_data):
shape = group.insert_shape(0, objects.Path())
shapes.append(shape)
shape.shape.value = bezier
return shapes
def raster_to_bezier(self, mono_data):
bmp = potrace.Bitmap(mono_data)
path = bmp.trace()
shapes = []
for curve in path:
bezier = objects.Bezier()
shapes.append(bezier)
bezier.add_point(NVector(*curve.start_point))
for segment in curve:
if segment.is_corner:
bezier.add_point(NVector(*segment.c))
bezier.add_point(NVector(*segment.end_point))
else:
sp = NVector(*bezier.vertices[-1])
ep = NVector(*segment.end_point)
c1 = NVector(*segment.c1) - sp
c2 = NVector(*segment.c2) - ep
bezier.out_tangents[-1] = c1
bezier.add_point(ep, c2)
return shapes
def _frame_keyframe(self, layer, group, time, shapes, beziers):
if shapes:
# TODO handle multiple shapes
nverts = len(beziers[0].vertices)
if nverts > layer._max_verts[group.name]:
layer._max_verts[group.name] = nverts
for shape, bezier in zip(shapes, beziers):
shape.shape.add_keyframe(time, bezier)
def raster_to_frame(self, animation, raster, layer_name, time, mode=QuanzationMode.Nearest):
mono_data = raster.quantize(self.palette, mode)
if layer_name not in self.layers:
layer = self.prepare_layer(animation, layer_name)
for (color, bitmap), group in zip(mono_data, layer.shapes):
shapes = self.raster_to_shapes(group, bitmap)
beziers = [s.shape.value for s in shapes]
self._frame_keyframe(layer, group, time, shapes, beziers)
else:
layer = self.layers[layer_name]
for (color, bitmap), group in zip(mono_data, layer.shapes):
shapes = [s for s in group.shapes if isinstance(s, objects.Path)]
beziers = self.raster_to_bezier(bitmap)
self._frame_keyframe(layer, group, time, shapes, beziers)
def adjust_missing_vertices(self, layer_name):
layer = self.layers[layer_name]
for group in layer.shapes:
# TODO handle multiple shapes
shape = group.shapes[0]
nverts = layer._max_verts[group.name]
if shape.shape.animated:
for kf in shape.shape.keyframes:
bezier = kf.start
count = nverts - len(bezier.vertices)
bezier.vertices += [bezier.vertices[-1]] * count
bezier.in_tangents += [NVector(0, 0)] * count
bezier.out_tangents += [NVector(0, 0)] * count
def duplicate_start_frame(self, layer_name, time):
layer = self.layers[layer_name]
for group in layer.shapes:
shape = group.shapes[0]
bezier = shape.shape.keyframes[0].start
group.shapes[0].shape.add_keyframe(time, bezier)
def color2numpy(vcolor):
l = (vcolor * 255).components
if len(l) == 3:
l.append(255)
return numpy.array(l, numpy.uint8)
def raster_to_animation(filenames, n_colors=1, frame_delay=1,
looping=True, framerate=60, palette=[],
mode=QuanzationMode.Nearest):
vc = Vectorizer()
def callback(animation, raster, frame):
raster = RasterImage.from_pil(raster)
if vc.palette is None:
if palette:
vc.palette = [color2numpy(c) for c in palette]
elif n_colors > 1:
vc.palette = raster.k_means(n_colors)
#vc.raster_to_frame(animation, raster, "anim", frame * frame_delay, mode)
layer = vc.raster_to_layer(animation, raster, "frame_%s" % frame, mode)
layer.in_point = frame * frame_delay
layer.out_point = (frame + 1) * frame_delay
animation = _vectorizing_func(filenames, frame_delay, framerate, callback)
#vc.adjust_missing_vertices("anim")
#if looping and animation._nframes > 1:
#animation.out_point += frame_delay
#vc.duplicate_start_frame("anim", animation.out_point)
#elif animation._nframes == 1:
#for g in animation.find("anim").shapes:
#for shape in g.find_all(objects.Path):
#shape.shape.clear_animation(shape.shape.get_value(0))
#animation.find("anim").out_point = animation.out_point
return animation
+5
View File
@@ -0,0 +1,5 @@
from . import builder, importer
from .importer import parse_sif_file
from .builder import to_sif
__all__ = ["builder", "importer", "parse_sif_file", "to_sif"]
Binary file not shown.
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
from .sif.nodes import *
from . import ast
from .ast_impl.base import SifKeyframe, Interpolation
+2
View File
@@ -0,0 +1,2 @@
from .ast_impl.nodes import *
from .ast_impl.base import *
+120
View File
@@ -0,0 +1,120 @@
from xml.dom import minidom
import enum
from lottie.parsers.sif.sif.core import TypeDescriptor, ObjectRegistry, SifNodeMeta, FrameTime
from lottie.parsers.sif.xml.utils import xml_child_elements, xml_first_element_child
class SifAstNode:
_subclasses = None
_tag = None
@staticmethod
def from_dom(xml: minidom.Element, param: TypeDescriptor, registry: ObjectRegistry):
if xml.tagName == param.typename:
return SifValue.from_dom(xml, param, registry)
xmltype = xml.getAttribute("type")
if xmltype != param.typename and xmltype != "weighted_" + param.typename:
raise ValueError("Invalid type %s (should be %s)" % (xmltype, param.typename))
return SifAstNode.ast_node_types()[xml.tagName].from_dom(xml, param, registry)
@staticmethod
def ast_node_types():
if SifAstNode._subclasses is None:
from . import nodes
SifAstNode._subclasses = {}
SifAstNode._gather_ast_types(SifAstNode)
return SifAstNode._subclasses
@staticmethod
def _gather_ast_types(cls):
for subcls in cls.__subclasses__():
if subcls._tag:
SifAstNode._subclasses[subcls._tag] = subcls
SifAstNode._gather_ast_types(subcls)
def _prepare_to_dom(self, dom: minidom.Document, param: TypeDescriptor):
element = dom.createElement(self._tag)
element.setAttribute("type", param.typename)
return element
class SifValue(SifAstNode):
def __init__(self, value=None):
self.value = value
def __repr__(self):
return "<%s %r>" % (self.__class__.__name__, self.value)
@classmethod
def from_dom(cls, xml: minidom.Element, param: TypeDescriptor, registry: ObjectRegistry):
return SifValue(param.value_from_xml_element(xml, registry))
def to_dom(self, dom: minidom.Document, param: TypeDescriptor):
return param.value_to_xml_element(self.value, dom)
class Interpolation(enum.Enum):
Auto = "auto"
Linear = "linear"
Clamped = "clamped"
Ease = "halt"
Constant = "constant"
class SifKeyframe:
def __init__(self, value, time: FrameTime, before=Interpolation.Clamped, after=Interpolation.Clamped):
self.value = value
self.time = time
self.before = before
self.after = after
@classmethod
def from_dom(cls, xml: minidom.Element, param: TypeDescriptor, registry: ObjectRegistry):
return cls(
param.value_from_xml_element(xml_first_element_child(xml), registry),
FrameTime.parse_string(xml.getAttribute("time"), registry),
Interpolation(xml.getAttribute("before")),
Interpolation(xml.getAttribute("after"))
)
def to_dom(self, dom: minidom.Document, param: TypeDescriptor):
element = dom.createElement("waypoint")
element.setAttribute("time", str(self.time))
element.setAttribute("before", self.before.value)
element.setAttribute("after", self.after.value)
element.appendChild(param.value_to_xml_element(self.value, dom))
return element
def __repr__(self):
return "<SifKeyframe %s %s>" % (self.time, self.value)
class SifAnimated(SifAstNode):
_tag = "animated"
def __init__(self):
self.keyframes = []
@classmethod
def from_dom(cls, xml: minidom.Element, param: TypeDescriptor, registry: ObjectRegistry):
obj = SifAnimated()
for waypoint in xml_child_elements(xml, "waypoint"):
obj.keyframes.append(SifKeyframe.from_dom(waypoint, param, registry))
return obj
def to_dom(self, dom: minidom.Document, param: TypeDescriptor):
element = self._prepare_to_dom(dom, param)
for kf in self.keyframes:
element.appendChild(kf.to_dom(dom, param))
return element
def add_keyframe(self, *args, **kwargs):
if not kwargs and len(args) == 1 and isinstance(args[0], SifKeyframe):
keyframe = args[0]
else:
keyframe = SifKeyframe(*args, **kwargs)
self.keyframes.append(keyframe)
return keyframe
+321
View File
@@ -0,0 +1,321 @@
from xml.dom import minidom
import enum
from lottie.nvector import NVector
from lottie.parsers.sif.ast_impl.base import SifAstNode, TypeDescriptor, ObjectRegistry
from lottie.parsers.sif.xml.animatable import XmlAnimatable
from lottie.parsers.sif.xml.wrappers import XmlBoneReference, XmlSifElement, XmlList
from lottie.parsers.sif.sif.nodes import Segment, WeightedVector, Bline
from lottie.parsers.sif.sif.enums import Smooth
from lottie.parsers.sif.sif.core import SifNodeMeta, FrameTime
class SifAstComplex(SifAstNode, metaclass=SifNodeMeta):
_nodes = []
def __init__(self, **kw):
for node in self._nodes:
node.initialize_object(kw, self)
@classmethod
def from_dom(cls, xml: minidom.Element, param: TypeDescriptor, registry: ObjectRegistry):
outcls = cls.get_class_from_dom(xml, param, registry)
instance = outcls()
for node in outcls._nodes:
node.from_xml(instance, xml, registry)
return instance
@classmethod
def get_class_from_dom(cls, xml: minidom.Element, param: TypeDescriptor, registry: ObjectRegistry):
return cls
def to_dom(self, dom: minidom.Document, param: TypeDescriptor):
element = self._prepare_to_dom(dom, param)
for node in self._nodes:
node.to_xml(self, element, dom, param)
return element
class SifAstBoneLink(SifAstComplex):
_tag = "bone_link"
_nodes = [
XmlBoneReference("bone"),
XmlAnimatable("base_value", "vector", NVector(0, 0)),
XmlAnimatable("translate", "bool", True),
XmlAnimatable("rotate", "bool", True),
XmlAnimatable("skew", "bool", True),
XmlAnimatable("scale_x", "bool", True),
XmlAnimatable("scale_y", "bool", True),
]
class SifAstBoneInfluence(SifAstComplex):
_tag = "boneinfluence"
_nodes = [
# TODO bone_weight_list
XmlAnimatable("link", "vector", NVector(0, 0)),
]
class SifSegCalcTangent(SifAstComplex):
_tag = "segcalctangent"
_nodes = [
XmlSifElement("segment", Segment),
XmlAnimatable("amount", "real", .5),
]
class SifSegCalcVertex(SifAstComplex):
_tag = "segcalcvertex"
_nodes = [
XmlSifElement("segment", Segment),
XmlAnimatable("amount", "real", .5),
]
class WeightedAverage(SifAstComplex):
_tag = "weighted_average"
_nodes = [
XmlList(WeightedVector, "vectors", "entry"),
]
def _prepare_to_dom(self, dom: minidom.Document, param: TypeDescriptor):
element = dom.createElement(self._tag)
element.setAttribute("type", "weighted_vector")
return element
class SifAdd(SifAstComplex):
_tag = "add"
_nodes = [
XmlAnimatable("lhs", "_recurse"),
XmlAnimatable("rhs", "_recurse"),
XmlAnimatable("scalar", "real", 1.),
]
class SifAnimatedFile(SifAstComplex):
_tag = "animated_file"
_nodes = [
XmlAnimatable("filename", "string"),
]
class Accuracy(enum.Enum):
Rough = 0
Normal = 1
Fine = 2
Extreme = 3
class DerivativeOrder:
FirstDerivative = 0
SecondDerivative = 1
class SifDerivative(SifAstComplex):
_tag = "derivative"
_nodes = [
XmlAnimatable("link", "_recurse"),
XmlAnimatable("interval", "real", 0.01),
XmlAnimatable("accuracy", "integer", Accuracy.Normal, Accuracy),
XmlAnimatable("order", "integer", DerivativeOrder.FirstDerivative, DerivativeOrder),
]
class SifDynamic(SifAstComplex):
_tag = "dynamic"
_nodes = [
XmlAnimatable("tip_static", "vector", NVector(0, 0)),
XmlAnimatable("origin", "vector", NVector(0, 0)),
XmlAnimatable("force", "vector", NVector(0, 0)),
XmlAnimatable("torque", "real", 0.),
XmlAnimatable("damping", "real", 0.4),
XmlAnimatable("friction", "real", 0.4),
XmlAnimatable("spring", "real", 30.),
XmlAnimatable("torsion", "real", 30.),
XmlAnimatable("mass", "real", 0.3),
XmlAnimatable("inertia", "real", 0.3),
XmlAnimatable("spring_rigid", "bool", False),
XmlAnimatable("torsion_rigid", "bool", False),
XmlAnimatable("origin_drags_tip", "bool", True),
]
class SifGreyed(SifAstComplex):
_tag = "greyed"
_nodes = [
XmlAnimatable("link", "_recurse"),
]
class SifLinear(SifAstComplex):
_tag = "linear"
_nodes = [
XmlAnimatable("slope", "vector", NVector(0, 0)),
XmlAnimatable("offset", "vector", NVector(0, 0)),
]
class SifRadialComposite(SifAstComplex):
_tag = "radial_composite"
_nodes = [
XmlAnimatable("radius", "real", 0.),
XmlAnimatable("theta", "angle", 0.),
]
class SifComposite(SifAstComplex):
_tag = "composite"
@classmethod
def get_class_from_dom(cls, xml: minidom.Element, param: TypeDescriptor, registry: ObjectRegistry):
type = xml.getAttribute("type")
if type == "vector":
return SifVectorComposite
return None
def _prepare_to_dom(self, dom: minidom.Document, param: TypeDescriptor):
element = dom.createElement("composite")
element.setAttribute("type", param.typename)
return element
class SifVectorComposite(SifComposite):
_type = "vector"
_nodes = [
XmlAnimatable("x", "real", 0.),
XmlAnimatable("y", "real", 0.),
]
class SifRandom(SifAstComplex):
_tag = "random"
_nodes = [
XmlAnimatable("link", "_recurse"),
XmlAnimatable("radius", "real", 0.),
XmlAnimatable("seed", "integer", 0),
XmlAnimatable("speed", "real", 1.),
XmlAnimatable("smooth", "integer", Smooth.Cubic, Smooth),
XmlAnimatable("loop", "real", 0.),
]
class SifReference(SifAstComplex):
_tag = "link"
_nodes = [
XmlAnimatable("reference", "_recurse"),
]
class SifScale(SifAstComplex):
_tag = "scale"
_nodes = [
XmlAnimatable("link", "_recurse"),
XmlAnimatable("scalar", "real", 1.),
]
class SifStep(SifAstComplex):
_tag = "step"
_nodes = [
XmlAnimatable("link", "_recurse"),
XmlAnimatable("duration", "time", FrameTime(1, FrameTime.Unit.Seconds)),
XmlAnimatable("start_time", "time", FrameTime(0, FrameTime.Unit.Seconds)),
XmlAnimatable("intersection", "real", 0.5),
]
class SifSubtract(SifAstComplex):
_tag = "subtract"
_nodes = [
XmlAnimatable("lhs", "_recurse"),
XmlAnimatable("rhs", "_recurse"),
XmlAnimatable("scalar", "real", 1.),
]
class SifSwitch(SifAstComplex):
_tag = "switch"
_nodes = [
XmlAnimatable("link_off", "_recurse"),
XmlAnimatable("link_on", "_recurse"),
XmlAnimatable("switch", "bool", False),
]
class SifTimedSwap(SifAstComplex):
_tag = "timed_swap"
_nodes = [
XmlAnimatable("before", "_recurse"),
XmlAnimatable("after", "_recurse"),
XmlAnimatable("time", "time", FrameTime(0, FrameTime.Unit.Seconds)),
XmlAnimatable("length", "time", FrameTime(0, FrameTime.Unit.Seconds)),
]
class SifTimeLoop(SifAstComplex):
_tag = "timeloop"
_nodes = [
XmlAnimatable("link", "_recurse"),
XmlAnimatable("link_time", "time", FrameTime(0, FrameTime.Unit.Seconds)),
XmlAnimatable("local_time", "time", FrameTime(0, FrameTime.Unit.Seconds)),
XmlAnimatable("duration", "time", FrameTime(0, FrameTime.Unit.Seconds)),
]
class SifPower(SifAstComplex):
_tag = "power"
_nodes = [
XmlAnimatable("base", "real", 1.),
XmlAnimatable("power", "real", 1.),
XmlAnimatable("epsilon", "real", 0.000001),
XmlAnimatable("infinite", "real", 999999.),
]
class SifBlineCalcTangent(SifAstComplex):
_tag = "blinecalctangent"
_nodes = [
XmlSifElement("bline", Bline),
XmlAnimatable("loop", "bool", False),
XmlAnimatable("amount", "real", 0.5),
XmlAnimatable("offset", "angle", 0.),
XmlAnimatable("scale", "real", 1.),
XmlAnimatable("fixed_length", "bool", False),
XmlAnimatable("homogeneous", "bool", False),
]
class SifBlineCalcVertex(SifAstComplex):
_tag = "blinecalcvertex"
_nodes = [
XmlSifElement("bline", Bline),
XmlAnimatable("loop", "bool", False),
XmlAnimatable("amount", "real", 0.5),
XmlAnimatable("homogeneous", "bool", False),
]
+411
View File
@@ -0,0 +1,411 @@
import math
from xml.dom import minidom
from ... import objects
from ...nvector import NVector
from ...utils import restructure
from . import api, ast
blend_modes = {
objects.BlendMode.Normal: api.BlendMethod.Composite,
objects.BlendMode.Multiply: api.BlendMethod.Multiply,
objects.BlendMode.Screen: api.BlendMethod.Screen,
objects.BlendMode.Overlay: api.BlendMethod.Overlay,
objects.BlendMode.Darken: api.BlendMethod.Darken,
objects.BlendMode.Lighten: api.BlendMethod.Lighten,
objects.BlendMode.HardLight: api.BlendMethod.HardLight,
objects.BlendMode.Difference: api.BlendMethod.Difference,
objects.BlendMode.Hue: api.BlendMethod.Hue,
objects.BlendMode.Saturation: api.BlendMethod.Saturation,
objects.BlendMode.Color: api.BlendMethod.Color,
objects.BlendMode.Luminosity: api.BlendMethod.Luminosity,
objects.BlendMode.Exclusion: api.BlendMethod.Difference,
objects.BlendMode.SoftLight: api.BlendMethod.Multiply,
objects.BlendMode.ColorDodge: api.BlendMethod.Composite,
objects.BlendMode.ColorBurn: api.BlendMethod.Composite,
}
class SifBuilder(restructure.AbstractBuilder):
def __init__(self, gamma=1.0):
"""
@todo Add gamma option to lottie_convert.py
"""
super().__init__()
self.canvas = api.Canvas()
self.canvas.version = "1.2"
self.canvas.gamma_r = self.canvas.gamma_g = self.canvas.gamma_b = gamma
self.autoid = objects.base.Index()
def _on_animation(self, animation: objects.Animation):
if animation.name:
self.canvas.name = animation.name
self.canvas.width = animation.width
self.canvas.height = animation.height
self.canvas.xres = animation.width
self.canvas.yres = animation.height
self.canvas.view_box = NVector(0, 0, animation.width, animation.height)
self.canvas.fps = animation.frame_rate
self.canvas.begin_time = api.FrameTime.frame(animation.in_point)
self.canvas.end_time = api.FrameTime.frame(animation.out_point)
self.canvas.antialias = True
return self.canvas
def _on_precomp(self, id, dom_parent, layers):
g = dom_parent.add_layer(api.GroupLayer())
g.desc = id
for layer_builder in layers:
self.process_layer(layer_builder, g)
def _on_layer(self, layer_builder, dom_parent):
layer = self.layer_from_lottie(api.GroupLayer, layer_builder.lottie, dom_parent)
if not layer_builder.lottie.name:
layer.desc = layer_builder.lottie.__class__.__name__
bm = getattr(layer_builder.lottie, "blend_mode", None)
if bm is None:
bm = objects.BlendMode.Normal
layer.blend_method = blend_modes[bm]
layer.time_drilation = getattr(layer_builder.lottie, "stretch", 1) or 1
in_point = getattr(layer_builder.lottie, "in_point", 0)
layer.time_offset.value = api.FrameTime.frame(in_point)
#layer.canvas.end_time = api.FrameTime.frame(out_point)
return layer
def layer_from_lottie(self, type, lottie, dom_parent):
g = dom_parent.add_layer(type())
if lottie.name:
g.desc = lottie.name
g.active = not lottie.hidden
transf = getattr(lottie, "transform", None)
if transf:
self.set_transform(g, transf)
if isinstance(lottie, objects.NullLayer):
g.amount.value = 1
return g
def _get_scale(self, transform):
def func(keyframe):
t = keyframe.time if keyframe else 0
scale_x, scale_y = transform.scale.get_value(t)[:2]
scale_x /= 100
scale_y /= 100
skew = transform.skew.get_value(t) if transform.skew else 0
c = math.cos(skew * math.pi / 180)
if c != 0:
scale_y *= 1 / c
return NVector(scale_x, scale_y)
return func
def set_transform(self, group, transform):
composite = group.transformation
if transform.position:
composite.offset = self.process_vector(transform.position)
if transform.scale:
keyframes = self._merge_keyframes([transform.scale, transform.skew])
composite.scale = self.process_vector_ext(keyframes, self._get_scale(transform))
composite.skew_angle = self.process_scalar(transform.skew or objects.Value(0))
if transform.rotation:
composite.angle = self.process_scalar(transform.rotation)
if transform.opacity:
group.amount = self.process_scalar(transform.opacity, 1/100)
if transform.anchor_point:
group.origin = self.process_vector(transform.anchor_point)
# TODO get z_depth from position
composite.z_depth = 0
def process_vector(self, multidim):
def getter(keyframe):
if keyframe is None:
v = multidim.value
else:
v = keyframe.start
return NVector(v[0], v[1])
return self.process_vector_ext(multidim.keyframes, getter)
def process_vector_ext(self, kframes, getter):
if kframes is not None:
wrap = ast.SifAnimated()
for i in range(len(kframes)):
keyframe = kframes[i]
waypoint = wrap.add_keyframe(getter(keyframe), api.FrameTime.frame(keyframe.time))
if i > 0:
prev = kframes[i-1]
if prev.jump:
waypoint.before = api.Interpolation.Constant
elif prev.in_value and prev.in_value.x < 1:
waypoint.before = api.Interpolation.Ease
else:
waypoint.before = api.Interpolation.Linear
else:
waypoint.before = api.Interpolation.Linear
if keyframe.jump:
waypoint.after = api.Interpolation.Constant
elif keyframe.out_value and keyframe.out_value.x > 0:
waypoint.after = api.Interpolation.Ease
else:
waypoint.after = api.Interpolation.Linear
else:
wrap = api.SifValue(getter(None))
return wrap
def process_scalar(self, value, mult=None):
def getter(keyframe):
if keyframe is None:
v = value.value
else:
v = keyframe.start[0]
if mult is not None:
v *= mult
return v
return self.process_vector_ext(value.keyframes, getter)
def _on_shape(self, shape, group, dom_parent):
layers = []
if not hasattr(shape, "to_bezier"):
return []
if group.stroke:
sif_shape = self.build_path(api.OutlineLayer, shape.to_bezier(), dom_parent, shape)
self.apply_group_stroke(sif_shape, group.stroke)
layers.append(sif_shape)
if group.fill:
sif_shape = self.build_path(api.RegionLayer, shape.to_bezier(), dom_parent, shape)
layers.append(sif_shape)
self.apply_group_fill(sif_shape, group.fill)
return layers
def _merge_keyframes(self, props):
keyframes = {}
for prop in props:
if prop is not None and prop.animated:
keyframes.update({kf.time: kf for kf in prop.keyframes})
return list(sorted(keyframes.values(), key=lambda kf: kf.time)) or None
def apply_origin(self, sif_shape, lottie_shape):
if hasattr(lottie_shape, "position"):
sif_shape.origin.value = lottie_shape.position.get_value()
else:
sif_shape.origin.value = lottie_shape.bounding_box().center()
def apply_group_fill(self, sif_shape, fill):
## @todo gradients?
if hasattr(fill, "colors"):
return
def getter(keyframe):
if keyframe is None:
v = fill.color.value
else:
v = keyframe.start
return self.canvas.make_color(*v)
sif_shape.color = self.process_vector_ext(fill.color.keyframes, getter)
def get_op(keyframe):
if keyframe is None:
v = fill.opacity.value
else:
v = keyframe.start[0]
v /= 100
return v
sif_shape.amount = self.process_vector_ext(fill.opacity.keyframes, get_op)
def apply_group_stroke(self, sif_shape, stroke):
self.apply_group_fill(sif_shape, stroke)
sif_shape.sharp_cusps.value = stroke.line_join == objects.LineJoin.Miter
round_cap = stroke.line_cap == objects.LineCap.Round
sif_shape.round_tip_0.value = round_cap
sif_shape.round_tip_1.value = round_cap
sif_shape.width = self.process_scalar(stroke.width, 0.5)
def build_path(self, type, path, dom_parent, lottie_shape):
layer = self.layer_from_lottie(type, lottie_shape, dom_parent)
self.apply_origin(layer, lottie_shape)
startbez = path.shape.get_value()
layer.bline.loop = startbez.closed
nverts = len(startbez.vertices)
for point in range(nverts):
self.bezier_point(path, point, layer.bline, layer.origin.value)
return layer
def bezier_point(self, lottie_path, point_index, sif_parent, offset):
composite = api.BlinePoint()
def get_point(keyframe):
if keyframe is None:
bezier = lottie_path.shape.value
else:
bezier = keyframe.start
if not bezier:
#elem.parentNode.parentNode.removeChild(elem.parentNode)
return
vert = bezier.vertices[point_index]
return NVector(vert[0], vert[1]) - offset
composite.point = self.process_vector_ext(lottie_path.shape.keyframes, get_point)
composite.split.value = True
composite.split_radius.value = True
composite.split_angle.value = True
def get_tangent(keyframe):
if keyframe is None:
bezier = lottie_path.shape.value
else:
bezier = keyframe.start
if not bezier:
#elem.parentNode.parentNode.removeChild(elem.parentNode)
return
inp = getattr(bezier, which_point)[point_index]
return NVector(inp.x, inp.y) * 3 * mult
mult = -1
which_point = "in_tangents"
composite.t1 = self.process_vector_ext(lottie_path.shape.keyframes, get_tangent)
mult = 1
which_point = "out_tangents"
composite.t2 = self.process_vector_ext(lottie_path.shape.keyframes, get_tangent)
sif_parent.points.append(composite)
def _on_shapegroup(self, shape_group, dom_parent):
if shape_group.empty():
return
layer = self.layer_from_lottie(api.GroupLayer, shape_group.lottie, dom_parent)
self.shapegroup_process_children(shape_group, layer)
def _modifier_inner_group(self, modifier, shapegroup, dom_parent):
layer = dom_parent.add_layer(api.GroupLayer())
self.shapegroup_process_child(modifier.child, shapegroup, layer)
return layer
def _on_shape_modifier(self, modifier, shapegroup, dom_parent):
layer = dom_parent.add_layer(api.GroupLayer())
if modifier.lottie.name:
layer.desc = modifier.lottie.name
inner = self._modifier_inner_group(modifier, shapegroup, layer)
if isinstance(modifier.lottie, objects.Repeater):
self.build_repeater(modifier.lottie, inner, layer)
def _build_repeater_defs(self, shape, name_id):
dup = api.Duplicate()
dup.id = name_id
self.canvas.defs.append(dup)
self.canvas.register_as(dup, name_id)
def getter(keyframe):
if keyframe is None:
v = shape.copies.value
else:
v = keyframe.start[0]
return v - 1
setattr(dup, "from", self.process_vector_ext(shape.copies.keyframes, getter))
dup.to.value = 0
dup.step.value = -1
return dup
def _build_repeater_transform_scale_component(self, shape, name_id, comp, scalecomposite):
power = ast.SifPower()
setattr(scalecomposite, "xy"[comp], power)
def getter(keyframe):
if keyframe is None:
v = shape.transform.scale.value
else:
v = keyframe.start
v = v[comp] / 100
return v
power.base = self.process_vector_ext(shape.transform.scale.keyframes, getter)
# HACK work around an issue in Synfig
power.power = ast.SifAdd()
power.power.lhs.value = api.ValueReference(name_id)
power.power.rhs.value = 0.000001
def _build_repeater_transform(self, shape, inner, name_id):
offset_id = name_id + "_origin"
origin = api.ExportedValue(offset_id, self.process_vector(shape.transform.anchor_point), "vector")
self.canvas.defs.append(origin)
self.canvas.register_as(origin, offset_id)
inner.origin = origin
composite = inner.transformation
composite.offset = ast.SifAdd()
composite.offset.rhs.value = api.ValueReference(offset_id)
composite.offset.lhs = ast.SifScale()
composite.offset.lhs.scalar.value = api.ValueReference(name_id)
composite.offset.lhs.link = self.process_vector(shape.transform.position)
composite.angle = ast.SifScale()
composite.angle.scalar.value = api.ValueReference(name_id)
composite.angle.link = self.process_scalar(shape.transform.rotation)
composite.scale = ast.SifVectorComposite()
self._build_repeater_transform_scale_component(shape, name_id, 0, composite.scale)
self._build_repeater_transform_scale_component(shape, name_id, 1, composite.scale)
def _build_repeater_amount(self, shape, inner, name_id):
inner.amount = ast.SifSubtract()
inner.amount.lhs = self.process_scalar(shape.transform.start_opacity, 0.01)
inner.amount.rhs = ast.SifScale()
inner.amount.rhs.scalar.value = api.ValueReference(name_id)
def getter(keyframe):
if keyframe is None:
t = 0
end = shape.transform.end_opacity.value
else:
t = keyframe.time
end = keyframe.start[0]
start = shape.transform.start_opacity.get_value(t)
n = shape.copies.get_value(t)
v = (start - end) / (n - 1) / 100 if n > 0 else 0
return v
inner.amount.rhs.link = self.process_vector_ext(shape.transform.end_opacity.keyframes, getter)
def build_repeater(self, shape, inner, dom_parent):
name_id = "duplicate_%s" % next(self.autoid)
dup = self._build_repeater_defs(shape, name_id)
self._build_repeater_transform(shape, inner, name_id)
self._build_repeater_amount(shape, inner, name_id)
inner.desc = "Transformation for " + (dom_parent.desc or "duplicate")
# duplicate layer
duplicate = dom_parent.add_layer(api.DuplicateLayer())
duplicate.index = dup
duplicate.desc = shape.name
def to_sif(animation):
builder = SifBuilder()
builder.process(animation)
return builder.canvas
+528
View File
@@ -0,0 +1,528 @@
import math
from ... import objects
from ...objects import easing
from . import api, ast
from ... import NVector, PolarVector
try:
from ...utils import font
has_font = True
except ImportError:
has_font = False
def convert(canvas: api.Canvas):
return Converter().convert(canvas)
class Converter:
def __init__(self):
pass
def _animated(self, sifval):
return isinstance(sifval, ast.SifAnimated)
def convert(self, canvas: api.Canvas):
self.canvas = canvas
self.animation = objects.Animation(
self._time(canvas.end_time),
canvas.fps
)
self.animation.in_point = self._time(canvas.begin_time)
self.animation.width = canvas.width
self.animation.height = canvas.height
self.view_p1 = NVector(canvas.view_box[0], canvas.view_box[1])
self.view_p2 = NVector(canvas.view_box[2], canvas.view_box[3])
self.target_size = NVector(canvas.width, canvas.height)
self.shape_layer = self.animation.add_layer(objects.ShapeLayer())
self.gamma = NVector(canvas.gamma_r, canvas.gamma_g, canvas.gamma_b)
self._process_layers(canvas.layers, self.shape_layer)
return self.animation
def _time(self, t: api.FrameTime):
return self.canvas.time_to_frames(t)
def _process_layers(self, layers, parent):
old_gamma = self.gamma
for layer in reversed(layers):
if not layer.active:
continue
elif isinstance(layer, api.GroupLayerBase):
parent.add_shape(self._convert_group(layer))
elif isinstance(layer, api.RectangleLayer):
parent.add_shape(self._convert_fill(layer, self._convert_rect))
elif isinstance(layer, api.CircleLayer):
parent.add_shape(self._convert_fill(layer, self._convert_circle))
elif isinstance(layer, api.StarLayer):
parent.add_shape(self._convert_fill(layer, self._convert_star))
elif isinstance(layer, api.PolygonLayer):
parent.add_shape(self._convert_fill(layer, self._convert_polygon))
elif isinstance(layer, api.RegionLayer):
parent.add_shape(self._convert_fill(layer, self._convert_bline))
elif isinstance(layer, api.AbstractOutline):
parent.add_shape(self._convert_outline(layer, self._convert_bline))
elif isinstance(layer, api.GradientLayer):
parent.add_shape(self._convert_gradient(layer, parent))
elif isinstance(layer, api.TransformDown):
shape = self._convert_transform_down(layer)
parent.add_shape(shape)
parent = shape
elif isinstance(layer, api.TextLayer):
if has_font:
parent.add_shape(self._convert_fill(layer, self._convert_text))
elif isinstance(layer, api.ColorCorrectLayer):
self.gamma = self.gamma * NVector(layer.gamma.value, layer.gamma.value, layer.gamma.value)
self.gamma = old_gamma
def _convert_group(self, layer: api.GroupLayer):
shape = objects.Group()
self._set_name(shape, layer)
shape.transform.anchor_point = self._adjust_coords(self._convert_vector(layer.origin))
self._convert_transform(layer.transformation, shape.transform)
self._process_layers(layer.layers, shape)
shape.transform.opacity = self._adjust_animated(
self._convert_scalar(layer.amount),
lambda x: x*100
)
return shape
def _convert_transform(self, sif_transform: api.AbstractTransform, lottie_transform: objects.Transform):
if isinstance(sif_transform, api.BoneLinkTransform):
base_transform = sif_transform.base_value
else:
base_transform = sif_transform
position = self._adjust_coords(self._convert_vector(base_transform.offset))
rotation = self._adjust_angle(self._convert_scalar(base_transform.angle))
scale = self._adjust_animated(
self._convert_vector(base_transform.scale),
lambda x: x * 100
)
lottie_transform.skew_axis = self._adjust_angle(self._convert_scalar(base_transform.skew_angle))
if isinstance(sif_transform, api.BoneLinkTransform):
lottie_transform.position = position
lottie_transform.rotation = rotation
lottie_transform.scale = scale
#bone = sif_transform.bone
#b_pos = self._adjust_coords(self._convert_vector(bone.origin))
#old_anchor = lottie_transform.anchor_point
#if sif_transform.translate:
#self._mix_animations_into(
#[position, b_pos, old_anchor],
#lottie_transform.position,
#lambda base_p, bone_p, anchor: (anchor-self.target_size/2)/2+self.target_size/2
#)
#else:
#lottie_transform.position = position
#lottie_transform.anchor_point = b_pos
#lottie_transform.anchor_point.value += NVector(100,0)
#if sif_transform.rotate:
#b_rot = self._convert_scalar(bone.angle)
#self._mix_animations_into([rotation, b_rot], lottie_transform.rotation, lambda a, b: a-b)
#else:
#lottie_transform.rotation = rotation
#if sif_transform.scale_y:
#b_scale = self._convert_scalar(bone.scalelx)
#self._mix_animations_into(
#scale, b_scale, lottie_transform.scale,
#lambda a, b: NVector(a.x, a.y * b)
#)
#else:
#lottie_transform.scale = scale
else:
lottie_transform.position = position
lottie_transform.rotation = rotation
lottie_transform.scale = scale
def _mix_animations_into(self, animations, output, mix):
if not any(x.animated for x in animations):
output.value = mix(*(x.value for x in animations))
else:
for vals in self._mix_animations(*animations):
time = vals.pop(0)
output.add_keyframe(time, mix(*vals))
def _convert_fill(self, layer, converter):
shape = objects.Group()
self._set_name(shape, layer)
shape.add_shape(converter(layer))
if layer.invert.value:
shape.add_shape(objects.Rect(self.target_size/2, self.target_size))
fill = objects.Fill()
fill.color = self._convert_color(layer.color)
fill.opacity = self._adjust_animated(
self._convert_scalar(layer.amount),
lambda x: x * 100
)
shape.add_shape(fill)
return shape
def _convert_linecap(self, lc: api.LineCap):
if lc == api.LineCap.Rounded:
return objects.LineCap.Round
if lc == api.LineCap.Squared:
return objects.LineCap.Square
return objects.LineCap.Butt
def _convert_cusp(self, lc: api.CuspStyle):
if lc == api.CuspStyle.Miter:
return objects.LineJoin.Miter
if lc == api.CuspStyle.Bevel:
return objects.LineJoin.Bevel
return objects.LineJoin.Round
def _convert_outline(self, layer: api.AbstractOutline, converter):
shape = objects.Group()
self._set_name(shape, layer)
shape.add_shape(converter(layer))
stroke = objects.Stroke()
stroke.color = self._convert_color(layer.color)
stroke.line_cap = self._convert_linecap(layer.start_tip)
stroke.line_join = self._convert_cusp(layer.cusp_type)
stroke.width = self._adjust_scalar(self._convert_scalar(layer.width))
shape.add_shape(stroke)
return shape
def _convert_rect(self, layer: api.RectangleLayer):
rect = objects.Rect()
p1 = self._adjust_coords(self._convert_vector(layer.point1))
p2 = self._adjust_coords(self._convert_vector(layer.point2))
if p1.animated or p2.animated:
for time, p1v, p2v in self._mix_animations(p1, p2):
rect.position.add_keyframe(time, (p1v + p2v) / 2)
rect.size.add_keyframe(time, abs(p2v - p1v))
pass
else:
rect.position.value = (p1.value + p2.value) / 2
rect.size.value = abs(p2.value - p1.value)
rect.rounded = self._adjust_scalar(self._convert_scalar(layer.bevel))
return rect
def _convert_circle(self, layer: api.CircleLayer):
shape = objects.Ellipse()
shape.position = self._adjust_coords(self._convert_vector(layer.origin))
radius = self._adjust_scalar(self._convert_scalar(layer.radius))
shape.size = self._adjust_add_dimension(radius, lambda x: NVector(x, x) * 2)
return shape
def _convert_star(self, layer: api.StarLayer):
shape = objects.Star()
shape.position = self._adjust_coords(self._convert_vector(layer.origin))
shape.inner_radius = self._adjust_scalar(self._convert_scalar(layer.radius2))
shape.outer_radius = self._adjust_scalar(self._convert_scalar(layer.radius1))
shape.rotation = self._adjust_animated(
self._convert_scalar(layer.angle),
lambda x: 90-x
)
shape.points = self._convert_scalar(layer.points)
if layer.regular_polygon.value:
shape.star_type = objects.StarType.Polygon
return shape
def _mix_animations(self, *animatable):
times = set()
for v in animatable:
self._force_animated(v)
for kf in v.keyframes:
times.add(kf.time)
for time in sorted(times):
yield [time] + [v.get_value(time) for v in animatable]
def _force_animated(self, lottieval):
if not lottieval.animated:
v = lottieval.value
lottieval.add_keyframe(0, v)
lottieval.add_keyframe(self.animation.out_point, v)
def _convert_easing_part(self, interp: api.Interpolation):
if interp == api.Interpolation.Linear:
return easing.Linear()
return easing.Sigmoid()
def _convert_easing(self, start: api.Interpolation, end: api.Interpolation):
if api.Interpolation.Constant in (start, end):
return easing.Jump()
if start == end:
return self._convert_easing_part(start)
return easing.Split(self._convert_easing_part(start), self._convert_easing_part(end))
def _convert_animatable(self, v: ast.SifAstNode, lot: objects.properties.AnimatableMixin):
if self._animated(v):
if len(v.keyframes) == 1:
lot.value = self._convert_ast_value(v.keyframes[0].value)
else:
for i, kf in enumerate(v.keyframes):
if i+1 < len(v.keyframes):
start = kf.after
end = v.keyframes[i+1].before
ease = self._convert_easing(start, end)
else:
ease = easing.Linear()
lot.add_keyframe(self._time(kf.time), self._convert_ast_value(kf.value), ease)
else:
lot.value = self._convert_ast_value(v)
return lot
def _convert_ast_value(self, v):
if isinstance(v, ast.SifRadialComposite):
return self._polar(v.radius.value, v.theta.value, 1)
elif isinstance(v, ast.SifValue):
return v.value
elif isinstance(v, ast.SifVectorComposite):
return NVector(v.x.value, v.y.value)
else:
return v
def _converted_vector_values(self, v):
if isinstance(v, ast.SifRadialComposite):
return [self._convert_scalar(v.radius), self._convert_scalar(v.theta)]
return self._convert_vector(v)
def _convert_color(self, v: ast.SifAstNode):
return self._adjust_animated(
self._convert_animatable(v, objects.ColorValue()),
self._color_gamma
)
def _convert_vector(self, v: ast.SifAstNode):
return self._convert_animatable(v, objects.MultiDimensional())
def _convert_scalar(self, v: ast.SifAstNode):
return self._convert_animatable(v, objects.Value())
def _color_gamma(self, color):
color = color.clone()
for i in range(3):
color[i] = color[i] ** (1/self.gamma[i])
return color
def _adjust_animated(self, lottieval, transform):
if lottieval.animated:
for kf in lottieval.keyframes:
if kf.start is not None:
kf.start = transform(kf.start)
if kf.end is not None:
kf.end = transform(kf.end)
else:
lottieval.value = transform(lottieval.value)
return lottieval
def _adjust_scalar(self, lottieval: objects.Value):
return self._adjust_animated(lottieval, self._scalar_mult)
def _adjust_angle(self, lottieval: objects.Value):
return self._adjust_animated(lottieval, lambda x: -x)
def _adjust_add_dimension(self, lottieval, transform):
to_val = objects.MultiDimensional()
to_val.animated = lottieval.animated
if lottieval.animated:
to_val.keyframes = []
for kf in lottieval.keyframes:
if kf.start is not None:
kf.start = transform(kf.start[0])
if kf.end is not None:
kf.end = transform(kf.end[0])
to_val.keyframes.append(kf)
else:
to_val.value = transform(lottieval.value)
return to_val
def _scalar_mult(self, x):
return x * 60
def _adjust_coords(self, lottieval: objects.MultiDimensional):
return self._adjust_animated(lottieval, self._coord)
def _coord(self, val: NVector):
return NVector(
self.target_size.x * (val.x / (self.view_p2.x - self.view_p1.x) + 0.5),
self.target_size.y * (val.y / (self.view_p2.y - self.view_p1.y) + 0.5),
)
def _convert_polygon(self, layer: api.PolygonLayer):
lot = objects.Path()
animatables = [self._convert_vector(layer.origin)] + [
self._convert_vector(p)
for p in layer.points
]
animated = any(x.animated for x in animatables)
if not animated:
lot.shape.value = self._polygon([x.value for x in animatables[1:]], animatables[0].value)
else:
for values in self._mix_animations(*animatables):
time = values[0]
origin = values[1]
points = values[2:]
lot.shape.add_keyframe(time, self._polygon(points, origin))
return lot
def _polygon(self, points, origin):
bezier = objects.Bezier()
bezier.closed = True
for point in points:
bezier.add_point(self._coord(point+origin))
return bezier
def _convert_bline(self, layer: api.AbstractOutline):
lot = objects.Path()
closed = layer.bline.loop
animatables = [
self._convert_vector(layer.origin)
]
for p in layer.bline.points:
animatables += [
self._convert_vector(p.point),
self._convert_scalar(p.t1.radius) if hasattr(p.t1, "radius") else objects.Value(0),
self._convert_scalar(p.t1.theta) if hasattr(p.t1, "radius") else objects.Value(0),
self._convert_scalar(p.t2.radius) if hasattr(p.t2, "radius") else objects.Value(0),
self._convert_scalar(p.t2.theta) if hasattr(p.t2, "radius") else objects.Value(0)
]
animated = any(x.animated for x in animatables)
if not animated:
lot.shape.value = self._bezier(
closed, [x.value for x in animatables[1:]], animatables[0].value, layer.bline.points
)
else:
for values in self._mix_animations(*animatables):
time = values[0]
origin = values[1]
values = values[2:]
lot.shape.add_keyframe(time, self._bezier(closed, values, origin, layer.bline.points))
return lot
def _bezier(self, closed, values, origin, points):
chunk_size = 5
bezier = objects.Bezier()
bezier.closed = closed
for i in range(0, len(values), chunk_size):
point, r1, a1, r2, a2 = values[i:i+chunk_size]
sifvert = point+origin
vert = self._coord(sifvert)
if not points[i//chunk_size].split_radius.value:
r2 = r1
if not points[i//chunk_size].split_angle.value:
a2 = a1
t1 = self._coord(sifvert + self._polar(r1, a1, 1)) - vert
t2 = self._coord(sifvert + self._polar(r2, a2, 2)) - vert
bezier.add_point(vert, t1, t2)
return bezier
def _polar(self, radius, angle, dir):
offset_angle = 0
if dir == 1:
offset_angle += 180
return PolarVector(radius/3, (angle+offset_angle) * math.pi / 180)
def _convert_transform_down(self, tl: api.TransformDown):
group = objects.Group()
self._set_name(group, tl)
if isinstance(tl, api.TranslateLayer):
group.transform.anchor_point.value = self.target_size / 2
group.transform.position = self._adjust_coords(self._convert_vector(tl.origin))
elif isinstance(tl, api.RotateLayer):
group.transform.anchor_point = self._adjust_coords(self._convert_vector(tl.origin))
group.transform.position = group.transform.anchor_point.clone()
group.transform.rotation = self._adjust_angle(self._convert_scalar(tl.amount))
elif isinstance(tl, api.ScaleLayer):
group.transform.anchor_point = self._adjust_coords(self._convert_vector(tl.center))
group.transform.position = group.transform.anchor_point.clone()
group.transform.scale = self._adjust_add_dimension(
self._convert_scalar(tl.amount),
self._zoom_to_scale
)
return group
def _zoom_to_scale(self, value):
zoom = math.e ** value * 100
return NVector(zoom, zoom)
def _set_name(self, lottie, sif):
lottie.name = sif.desc if sif.desc is not None else sif.__class__.__name__
def _convert_gradient(self, layer: api.GradientLayer, parent):
group = objects.Group()
parent_shapes = parent.shapes
parent.shapes = []
if isinstance(parent, objects.Group):
parent.shapes.append(parent_shapes[-1])
self._gradient_gather_shapes(parent_shapes, group)
gradient = objects.GradientFill()
self._set_name(gradient, layer)
group.add_shape(gradient)
gradient.colors = self._convert_gradient_stops(layer.gradient)
gradient.opacity = self._adjust_animated(
self._convert_scalar(layer.amount),
lambda x: x * 100
)
if isinstance(layer, api.LinearGradient):
gradient.start_point = self._adjust_coords(self._convert_vector(layer.p1))
gradient.end_point = self._adjust_coords(self._convert_vector(layer.p2))
gradient.gradient_type = objects.GradientType.Linear
elif isinstance(layer, api.RadialGradient):
gradient.gradient_type = objects.GradientType.Radial
gradient.start_point = self._adjust_coords(self._convert_vector(layer.center))
radius = self._adjust_animated(self._convert_scalar(layer.radius), lambda x: x*45)
if not radius.animated and not gradient.start_point.animated:
gradient.end_point.value = gradient.start_point.value + NVector(radius.value, radius.value)
else:
for time, c, r in self._mix_animations(gradient.start_point.clone(), radius):
gradient.end_point.add_keyframe(time, c + NVector(r + r))
return group
def _gradient_gather_shapes(self, shapes, output: objects.Group):
for shape in shapes:
if isinstance(shape, objects.Shape):
output.add_shape(shape)
elif isinstance(shape, objects.Group):
self._gradient_gather_shapes(shape.shapes, output)
def _convert_gradient_stops(self, sif_gradient):
stops = objects.GradientColors()
if not self._animated(sif_gradient):
stops.set_stops(self._flatten_gradient_colors(sif_gradient.value))
stops.count = len(sif_gradient.value)
else:
# TODO easing
for kf in sif_gradient.keyframes:
stops.add_keyframe(self._time(kf.time), self._flatten_gradient_colors(kf.value))
stops.count = len(kf.value)
return stops
def _flatten_gradient_colors(self, stops):
return [
(stop.pos, self._color_gamma(stop.color))
for stop in stops
]
def _convert_text(self, layer: api.TextLayer):
shape = font.FontShape(layer.text.value, font.FontStyle(layer.family.value, 110, font.TextJustify.Center))
shape.refresh()
trans = shape.wrapped.transform
trans.anchor_point.value = shape.wrapped.bounding_box().center()
trans.anchor_point.value.x /= 2
trans.position = self._adjust_coords(self._convert_vector(layer.origin))
trans.scale = self._adjust_animated(
self._convert_vector(layer.size),
lambda v: v * 100
)
return shape
+7
View File
@@ -0,0 +1,7 @@
from ..tgs import open_maybe_gzipped
def parse_sif_file(file):
from .converter import convert
from . import api
return convert(open_maybe_gzipped(file, api.Canvas.from_xml_file))
View File
+165
View File
@@ -0,0 +1,165 @@
from xml.dom import minidom
import enum
from uuid import uuid4
from lottie.nvector import NVector
from lottie.parsers.sif.xml.utils import xml_text, str_to_bool
from lottie.parsers.sif.xml.utils import xml_child_elements, value_from_xml_string, xml_make_text, value_to_xml_string
from lottie.parsers.sif.sif.frame_time import FrameTime
class ObjectRegistry:
def __init__(self):
self.registry = {}
def register_as(self, object, key):
self.registry[key] = object
def register(self, object):
guid = getattr(object, "guid", None)
if guid is None:
guid = self.guid()
object.guid = guid
self.registry[guid] = object
@classmethod
def guid(cls):
return str(uuid4()).replace("-", "").upper()
def get_object(self, guid):
return self.registry[guid]
def noop(x):
return x
class TypeDescriptor:
_type_tag_names = {
"bone_object": "bone"
}
def __init__(self, typename, default=None, type_wrapper=noop):
self.typename = typename
self.type_wrapper = type_wrapper
self.default_value = default
def value_to_xml_element(self, value, dom: minidom.Document):
element = dom.createElement(self.tag_name)
if self.typename == "vector":
element.appendChild(xml_make_text(dom, "x", str(value.x)))
element.appendChild(xml_make_text(dom, "y", str(value.y)))
if hasattr(value, "guid"):
element.setAttribute("guid", value.guid)
elif self.typename == "color":
element.appendChild(xml_make_text(dom, "r", str(value[0])))
element.appendChild(xml_make_text(dom, "g", str(value[1])))
element.appendChild(xml_make_text(dom, "b", str(value[2])))
element.appendChild(xml_make_text(dom, "a", str(value[3])))
if hasattr(value, "guid"):
element.setAttribute("guid", value.guid)
elif self.typename == "gradient":
for point in value:
element.appendChild(point.to_dom(dom))
elif self.typename == "bool":
element.setAttribute("value", "true" if value else "false")
elif self.typename == "bone_object":
element.setAttribute("guid", value.guid)
element.setAttribute("type", self.typename)
elif self.typename == "string":
element.appendChild(dom.createTextNode(value))
else:
if isinstance(value, enum.Enum):
value = value.value
element.setAttribute("value", str(value))
return element
@property
def tag_name(self):
return self._type_tag_names.get(self.typename, self.typename)
def value_from_xml_element(self, xml: minidom.Element, registry: ObjectRegistry):
if xml.tagName != self.tag_name:
raise ValueError("Wrong value type (%s instead of %s)" % (xml.tagName, self.tag_name))
guid = xml.getAttribute("guid")
if guid and guid in registry.registry:
value = registry.registry[guid]
elif self.typename == "vector":
value = NVector(
float(xml_text(xml.getElementsByTagName("x")[0])),
float(xml_text(xml.getElementsByTagName("y")[0]))
)
if xml.getAttribute("guid"):
value.guid = xml.getAttribute("guid")
registry.register(value)
elif self.typename == "color":
value = NVector(
float(xml_text(xml.getElementsByTagName("r")[0])),
float(xml_text(xml.getElementsByTagName("g")[0])),
float(xml_text(xml.getElementsByTagName("b")[0])),
float(xml_text(xml.getElementsByTagName("a")[0]))
)
elif self.typename == "gradient":
value = [
GradientPoint.from_dom(sub, registry)
for sub in xml_child_elements(xml, GradientPoint.type.typename)
]
elif self.typename == "real" or self.typename == "angle":
value = float(xml.getAttribute("value"))
elif self.typename == "integer":
value = int(xml.getAttribute("value"))
elif self.typename == "time":
value = FrameTime.parse_string(xml.getAttribute("value"), registry)
elif self.typename == "bool":
value = str_to_bool(xml.getAttribute("value"))
elif self.typename == "string":
return xml_text(xml)
elif self.typename == "bone_object":
# Already done above but this forces the guid to be present
return registry.get_object(xml.getAttribute("guid"))
else:
raise ValueError("Unsupported type %s" % self.typename)
return self.type_wrapper(value)
class GradientPoint:
type = TypeDescriptor("color")
def __init__(self, pos: float, color: NVector):
self.pos = pos
self.color = color
def to_dom(self, dom: minidom.Document):
element = self.type.value_to_xml_element(self.color, dom)
element.setAttribute("pos", value_to_xml_string(self.pos, float))
return element
@classmethod
def from_dom(cls, xml: minidom.Element, registry: ObjectRegistry):
return GradientPoint(
value_from_xml_string(xml.getAttribute("pos"), float, registry),
cls.type.value_from_xml_element(xml, registry)
)
def __repr__(self):
return "<GradientPoint %s %s>" % (self.pos, self.color)
class SifNodeMeta(type):
def __new__(cls, name, bases, attr):
props = []
for base in bases:
if type(base) == cls:
props += base._nodes
attr["_nodes"] = props + attr.get("_nodes", [])
if "_tag" not in attr:
attr["_tag"] = name.lower()
attr["_nodemap"] = {
node.att_name: node
for node in attr["_nodes"]
}
return super().__new__(cls, name, bases, attr)
+9
View File
@@ -0,0 +1,9 @@
import enum
class Smooth(enum.Enum):
NearestNeighbour = 0
Linear = 1
Cosine = 2
Spline = 3
Cubic = 4
+46
View File
@@ -0,0 +1,46 @@
import enum
class FrameTime:
class Unit(enum.Enum):
Frame = "f"
Seconds = "s"
def __init__(self, value, unit):
self.value = value
self.unit = unit
def __eq__(self, other):
return self.value == other.value and self.unit == other.unit
def __ne__(self, other):
return self.value == other.value and self.unit == other.unit
def __str__(self):
return "%s%s" % (self.value, self.unit.value)
def __repr__(self):
return "<%s %s>" % (self.__class__.__name__, self)
@classmethod
def frame(cls, amount):
return cls(amount, cls.Unit.Frame)
@classmethod
def seconds(cls, amount):
return cls(amount, cls.Unit.Seconds)
@classmethod
def parse_string(cls, value_str, canvas):
if " " in value_str:
value = 0
unit = cls.Unit.Frame
for sub in value_str.split():
sv = float(sub[:-1])
if sub[-1] == "s":
sv *= canvas.fps
value += sv
else:
value = float(value_str[:-1])
unit = cls.Unit(value_str[-1])
return FrameTime(value, unit)
File diff suppressed because it is too large Load Diff
View File
+160
View File
@@ -0,0 +1,160 @@
from xml.dom import minidom
import copy
import enum
from .core_nodes import XmlDescriptor, XmlSimpleElement, ValueReference
from .utils import *
from lottie.nvector import NVector
from lottie.parsers.sif.ast_impl.base import SifAstNode, SifValue
from lottie.parsers.sif.sif.core import ObjectRegistry, TypeDescriptor, noop
class XmlAnimatable(XmlDescriptor):
def __init__(self, name, typename, default=None, type_wrapper=noop):
super().__init__(name)
self.type = TypeDescriptor(typename, default, type_wrapper)
def from_xml(self, obj, parent: minidom.Element, registry: ObjectRegistry, param: TypeDescriptor = None):
cn = xml_first_element_child(parent, self.name)
if cn:
value = SifAstNode.from_dom(xml_first_element_child(cn), self.type_for(param), registry)
else:
value = self.default()
setattr(obj, self.att_name, value)
def to_xml(self, obj, parent: minidom.Element, dom: minidom.Document, type: TypeDescriptor = None):
value = getattr(obj, self.att_name)
if isinstance(value, SifValue) and isinstance(value.value, ValueReference):
parent.setAttribute(self.name, ":" + value.value.id)
return
param = parent.appendChild(dom.createElement(self.name))
param.appendChild(value.to_dom(dom, self.type_for(type)))
return param
def from_python(self, value):
if not isinstance(value, SifAstNode):
raise ValueError("%s isn't a valid value for %s" % (value, self.name))
return value
def default(self):
return SifValue(copy.deepcopy(self.type.default_value))
def type_for(self, param: TypeDescriptor):
if param is not None and self.type.typename == "_recurse":
return param
return self.type
class XmlParam(XmlDescriptor):
def __init__(self, name, typename, default=None, type_wrapper=noop, static=False):
super().__init__(name)
self.type = TypeDescriptor(typename, default, type_wrapper)
self.static = static
def _def(self):
from ..api import Def
return Def
def from_xml(self, obj, parent: minidom.Element, registry: ObjectRegistry):
for cn in xml_child_elements(parent, "param"):
if cn.getAttribute("name") == self.name:
use = cn.getAttribute("use")
if use:
value = registry.get_object(use)
else:
value_node = xml_first_element_child(cn)
if self.static:
value = self.type.value_from_xml_element(value_node, registry)
else:
value = SifAstNode.from_dom(value_node, self.type, registry)
break
else:
value = self.default()
setattr(obj, self.att_name, value)
def to_xml(self, obj, parent: minidom.Element, dom: minidom.Document):
param = parent.appendChild(dom.createElement("param"))
param.setAttribute("name", self.name)
value = getattr(obj, self.att_name)
if isinstance(value, self._def()):
param.setAttribute("use", ":" + value.id)
else:
if self.static:
elem = self.type.value_to_xml_element(value, dom)
else:
elem = value.to_dom(dom, self.type)
param.appendChild(elem)
return param
def from_python(self, value):
if self.static:
return self.type.type_wrapper(value)
if not isinstance(value, (SifAstNode, self._def())):
raise ValueError("%s isn't a valid value for %s" % (value, self.name))
return value
def default(self):
if self.static:
return copy.deepcopy(self.type.default_value)
return SifValue(copy.deepcopy(self.type.default_value))
class XmlDynamicListParam(XmlDescriptor):
_tag = "dynamic_list"
def __init__(self, name, typename, att_name=None):
super().__init__(name)
self.type = TypeDescriptor(typename)
if att_name is not None:
self.att_name = att_name
def to_xml(self, obj, parent: minidom.Element, dom: minidom.Document):
param = parent.appendChild(dom.createElement("param"))
param.setAttribute("name", self.name)
dyl = param.appendChild(dom.createElement(self._tag))
dyl.setAttribute("type", self.type.typename)
values = getattr(obj, self.att_name)
for val in values:
entry = dyl.appendChild(dom.createElement("entry"))
entry.appendChild(self._value_to_dom(val, dom))
def _value_to_dom(self, val, dom: minidom.Document):
return val.to_dom(dom, self.type)
def from_xml(self, obj, parent: minidom.Element, registry: ObjectRegistry):
values = []
for cn in xml_child_elements(parent, "param"):
if cn.getAttribute("name") == self.name:
list = xml_first_element_child(cn)
if list.getAttribute("type") != self.type.typename:
raise ValueError(
"Wrong type for %s: got %s instead of %s" %
(self.name, self.type.typename, list.getAttribute("type"))
)
for entry in xml_child_elements(list, "entry"):
values.append(self._value_from_dom(xml_first_element_child(entry), registry))
break
setattr(obj, self.att_name, values)
def _value_from_dom(self, element, registry):
return SifAstNode.from_dom(element, self.type, registry)
def from_python(self, value):
return value
def default(self):
return []
class XmlStaticListParam(XmlDynamicListParam):
_tag = "static_list"
def _value_to_dom(self, val, dom: minidom.Document):
return self.type.value_to_xml_element(val, dom)
def _value_from_dom(self, element: minidom.Element, registry: ObjectRegistry):
return self.type.value_from_xml_element(element, registry)
+142
View File
@@ -0,0 +1,142 @@
from xml.dom import minidom
import copy
from uuid import uuid4
from .utils import *
from lottie.parsers.sif.sif.core import ObjectRegistry
class XmlDescriptor:
def __init__(self, name):
self.name = name
self.att_name = name.replace("-", "_").replace("[", "_").replace("]", "").replace(".", "_")
def to_xml(self, obj, parent: minidom.Element, dom: minidom.Document):
raise NotImplementedError
def from_xml(self, obj, parent: minidom.Element, registry: ObjectRegistry):
raise NotImplementedError
def from_python(self, value):
raise NotImplementedError
def initialize_object(self, dict, obj):
if self.att_name in dict:
setattr(obj, self.att_name, self.from_python(dict[self.att_name]))
else:
setattr(obj, self.att_name, self.default())
def clean(self, value):
return self.from_python(value)
def default(self):
return None
def __repr__(self):
return "%s(%r)" % (self.__class__.__name__, self.name)
class TypedXmlDescriptor(XmlDescriptor):
def __init__(self, name, type=str, default_value=None, att_name=None):
super().__init__(name)
self.type = type
self.default_value = default_value
if att_name is not None:
self.att_name = att_name
def from_python(self, value):
if value is None and self.default_value is None:
return None
if not value_isinstance(value, self.type):
return self.type(value)
return value
def default(self):
return copy.deepcopy(self.default_value)
class ValueReference:
def __init__(self, id, value=None):
self.value = value
self.id = id
@classmethod
def from_registry(cls, id, registry: ObjectRegistry):
return cls(id, registry.get_object(id))
class XmlAttribute(TypedXmlDescriptor):
def from_xml(self, obj, parent: minidom.Element, registry: ObjectRegistry):
xml_str = parent.getAttribute(self.name)
if xml_str:
if xml_str.startswith(":") and xml_str[1:] in registry.registry:
value = ValueReference.from_registry(xml_str[1:], registry)
else:
value = value_from_xml_string(xml_str, self.type, registry)
setattr(obj, self.att_name, value)
def to_xml(self, obj, parent: minidom.Element, dom: minidom.Document):
value = getattr(obj, self.att_name)
if value is not None:
if isinstance(value, ValueReference):
xml_str = ":" + value.id
else:
xml_str = value_to_xml_string(value, self.type)
parent.setAttribute(self.name, xml_str)
class XmlFixedAttribute(XmlDescriptor):
def __init__(self, name, value, type=str):
super().__init__(name)
self.value = value
self.type = type
def to_xml(self, obj, parent: minidom.Element, dom: minidom.Document):
parent.setAttribute(self.name, value_to_xml_string(self.value, self.type))
def from_python(self, value):
if value != self.value:
raise ValueError("Value of %s should be %s, got %s" % (self.name, self.value, value))
return value
def from_xml(self, obj, parent: minidom.Element, registry: ObjectRegistry):
xml_str = parent.getAttribute(self.name)
setattr(obj, self.att_name, value_from_xml_string(xml_str, self.type, registry))
def default(self):
return self.value
class XmlSimpleElement(TypedXmlDescriptor):
def from_xml(self, obj, parent: minidom.Element, registry: ObjectRegistry):
cn = xml_first_element_child(parent, self.name, allow_none=True)
if cn:
value = value_from_xml_string(xml_text(cn), self.type, registry)
else:
value = self.default_value
setattr(obj, self.att_name, value)
def to_xml(self, obj, parent: minidom.Element, dom: minidom.Document):
value = getattr(obj, self.att_name)
if value is not None:
parent.appendChild(xml_make_text(dom, self.name, value_to_xml_string(value, self.type)))
class XmlMeta(TypedXmlDescriptor):
def from_xml(self, obj, parent: minidom.Element, registry: ObjectRegistry):
for cn in xml_child_elements(parent, "meta"):
if cn.getAttribute("name") == self.name:
value = value_from_xml_string(cn.getAttribute("content"), self.type, registry)
break
else:
value = self.default_value
setattr(obj, self.att_name, value)
def to_xml(self, obj, parent: minidom.Element, dom: minidom.Document):
value = getattr(obj, self.att_name)
if value is not None:
meta = parent.appendChild(dom.createElement("meta"))
meta.setAttribute("name", self.name)
meta.setAttribute("content", value_to_xml_string(value, self.type))
+85
View File
@@ -0,0 +1,85 @@
from xml.dom import minidom
from distutils.util import strtobool
from lottie.nvector import NVector
from lottie.parsers.sif.sif.frame_time import FrameTime
class _tag:
def __init__(self, type):
self.type = type
def __call__(self, v):
return self.type(v)
bool_str = _tag(bool)
def str_to_bool(strval):
return bool(strtobool(strval))
def value_from_xml_string(xml_str, type, registry):
if type in (bool_str, bool):
return str_to_bool(xml_str)
elif type is NVector:
return NVector(*map(float, xml_str.split()))
if type is FrameTime:
return FrameTime.parse_string(xml_str, registry)
return type(xml_str)
def value_to_xml_string(value, type):
if type is bool:
return "1" if value else "0"
if type is bool_str:
return "true" if value else "false"
elif type is NVector:
return " ".join(map(str, value))
return str(value)
def value_isinstance(value, type):
if isinstance(type, _tag):
type = type.type
return isinstance(value, type)
def xml_text(node):
return "".join(
x.nodeValue
for x in node.childNodes
if x.nodeType in {minidom.Node.TEXT_NODE, minidom.Node.CDATA_SECTION_NODE}
)
def xml_make_text(dom: minidom.Document, tag_name, text):
e = dom.createElement(tag_name)
e.appendChild(dom.createTextNode(text))
return e
def xml_element_matches(ch: minidom.Node, tagname=None):
if ch.nodeType != minidom.Node.ELEMENT_NODE:
return False
if tagname is not None and ch.tagName != tagname:
return False
return True
def xml_child_elements(xml: minidom.Node, tagname=None):
for ch in xml.childNodes:
if xml_element_matches(ch, tagname):
yield ch
def xml_first_element_child(xml: minidom.Node, tagname=None, allow_none=False):
for ch in xml_child_elements(xml, tagname):
return ch
if allow_none:
return None
raise ValueError("No %s in %s" % (tagname or "child element", getattr(xml, "tagName", "node")))
+217
View File
@@ -0,0 +1,217 @@
from .utils import *
from .core_nodes import XmlDescriptor, ObjectRegistry
class XmlParamSif(XmlDescriptor):
def __init__(self, name, child_node, default_ctor=None):
super().__init__(name)
self.child_node = child_node
self.default_ctor = default_ctor or child_node
def from_xml(self, obj, parent: minidom.Element, registry: ObjectRegistry):
for cn in xml_child_elements(parent, "param"):
if cn.getAttribute("name") == self.name:
value = self.child_node.from_dom(xml_first_element_child(cn), registry)
break
else:
value = self.default()
setattr(obj, self.att_name, value)
def to_xml(self, obj, parent: minidom.Element, dom: minidom.Document):
param = parent.appendChild(dom.createElement("param"))
param.setAttribute("name", self.name)
param.appendChild(getattr(obj, self.att_name).to_dom(dom))
return param
def clean(self, value):
if not isinstance(value, self.child_node):
raise ValueError("%s isn't a valid value for %s" % (value, self.name))
return value
def default(self):
return self.default_ctor()
class SifNodeList:
def __init__(self, type):
self._items = []
self._type = type
def __len__(self):
return len(self._items)
def __iter__(self):
return iter(self._items)
def __getitem__(self, name):
return self._items[name]
def __getslice__(self, i, j):
return self._items[i:j]
def __setitem__(self, key, value: "Layer"):
self.validate(value)
self._items[key] = value
def append(self, value: "Layer"):
self.validate(value)
self._items.append(value)
def __str__(self):
return str(self._items)
def __repr__(self):
return "<SifNodeList %s>" % self._items
def validate(self, value):
if not isinstance(value, self._type):
raise ValueError("Not a valid object: %s" % value)
class XmlSifElement(XmlDescriptor):
def __init__(self, name, child_node, nested=True):
super().__init__(name)
self.child_node = child_node
self.nested = nested
def default(self):
return self.child_node()
def from_python(self, value):
if not isinstance(value, self.child_node):
raise ValueError("Invalid value for %s: %s" % (self.name, value))
return value
def from_xml(self, obj, parent: minidom.Element, registry: ObjectRegistry):
cn = xml_first_element_child(parent, self.name, allow_none=True)
if cn:
if self.nested:
element = xml_first_element_child(cn)
else:
element = cn
value = self.child_node.from_dom(element, registry)
else:
value = self.default()
setattr(obj, self.att_name, value)
def to_xml(self, obj, parent: minidom.Element, dom: minidom.Document):
value = getattr(obj, self.att_name)
if self.nested:
node = dom.createElement(self.name)
parent.appendChild(node)
else:
node = parent
node.appendChild(value.to_dom(dom))
class XmlList(XmlDescriptor):
def __init__(self, child_node, name=None, wrapper_tag=None, tags=None):
super().__init__(wrapper_tag or child_node._tag)
self.child_node = child_node
self.att_name = self.att_name + "s" if name is None else name
self.wrapper_tag = wrapper_tag
if tags is None:
self.tags = {self.name}
else:
self.tags = tags
def default(self):
return SifNodeList(self.child_node)
def clean(self, value):
return value
def from_xml(self, obj, parent: minidom.Element, registry: ObjectRegistry):
values = self.default()
for cn in xml_child_elements(parent):
if cn.tagName in self.tags:
value_node = cn
if self.wrapper_tag:
value_node = xml_first_element_child(cn)
values.append(self.child_node.from_dom(value_node, registry))
setattr(obj, self.att_name, values)
def to_xml(self, obj, parent: minidom.Element, dom: minidom.Document):
for value in getattr(obj, self.att_name):
value_node = value.to_dom(dom)
if self.wrapper_tag:
wrapper = dom.createElement(self.wrapper_tag)
wrapper.appendChild(value_node)
value_node = wrapper
parent.appendChild(value_node)
class XmlWrapper(XmlDescriptor):
def __init__(self, name, wrapped: XmlDescriptor):
super().__init__(name)
self.wrapped = wrapped
def to_xml(self, obj, parent: minidom.Element, dom: minidom.Document):
wrapper = parent.appendChild(dom.createElement(self.name))
self.wrapped.to_xml(obj, wrapper, dom)
def from_xml(self, obj, parent: minidom.Element, registry: ObjectRegistry):
wrapper = xml_first_element_child(parent, self.name, True)
if wrapper:
return self.wrapped.from_xml(obj, wrapper, registry)
return self.default()
def from_python(self, value):
return self.wrapped.from_python(value)
def initialize_object(self, dict, obj):
return self.wrapped.initialize_object(dict, obj)
def clean(self, value):
return self.wrapped.clean(value)
def default(self):
return self.wrapped.default()
class XmlWrapperParam(XmlWrapper):
def to_xml(self, obj, parent: minidom.Element, dom: minidom.Document):
wrapper = parent.appendChild(dom.createElement("param"))
wrapper.setAttribute("name", self.name)
self.wrapped.to_xml(obj, wrapper, dom)
def from_xml(self, obj, parent: minidom.Element, registry: ObjectRegistry):
for wrapper in xml_child_elements(parent, "param"):
if wrapper.getAttribute("name") == self.name:
return self.wrapped.from_xml(obj, wrapper, registry)
return self.default()
class XmlBoneReference(XmlDescriptor):
def __init__(self, name):
super().__init__(name)
def to_xml(self, obj, parent: minidom.Element, dom: minidom.Document):
value = getattr(obj, self.name, None)
if not value:
return
node = parent.appendChild(dom.createElement(self.name))
value_node = node.appendChild(dom.createElement("bone_valuenode"))
value_node.setAttribute("type", value.type)
value_node.setAttribute("guid", value.guid)
return node
def from_xml(self, obj, parent: minidom.Element, registry: ObjectRegistry):
node = xml_first_element_child(parent, self.name, True)
value = None
if node:
value_node = xml_first_element_child(node, "bone_valuenode", True)
if value_node:
value = registry.get_object(value_node.getAttribute("guid"))
if value.type != value_node.getAttribute("type"):
raise ValueError("Bone type %s is not %s" % (value.type, value_node.getAttribute("type")))
setattr(obj, self.att_name, value)
def from_python(self, value):
return value
+3
View File
@@ -0,0 +1,3 @@
from .importer import parse_svg_etree, parse_svg_file
from . import builder, importer
__all__ = ["builder", "importer", "parse_svg_etree", "parse_svg_file"]
+719
View File
@@ -0,0 +1,719 @@
import re
import math
from xml.etree import ElementTree
from .handler import SvgHandler, NameMode
from ... import objects
from ...nvector import NVector
from ...utils import restructure
from ...utils.transform import TransformMatrix
try:
from ...utils import font
has_font = True
except ImportError:
has_font = False
class PrecompTime:
def __init__(self, pcl: objects.PreCompLayer):
self.pcl = pcl
def get_time_offset(self, time, lot):
remap = time
if self.pcl.time_remapping:
remapf = self.pcl.time_remapping.get_value(time)
remap = lot.in_point * (1-remapf) + lot.out_point * remapf
return remap - self.pcl.start_time
class SvgBuilder(SvgHandler, restructure.AbstractBuilder):
merge_paths = True
namestart = (
r":_A-Za-z\xC0-\xD6\xD8-\xF6\xF8-\u02FF\u0370-\u037D\u037F-\u1FFF" +
r"\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF" +
r"\uFDF0-\uFFFD\U00010000-\U000EFFFF"
)
namenostart = r"-.0-9\xB7\u0300-\u036F\u203F-\u2040"
id_re = re.compile("^[%s][%s%s]*$" % (namestart, namenostart, namestart))
def __init__(self, time=0):
super().__init__()
self.svg = ElementTree.Element("svg")
self.dom = ElementTree.ElementTree(self.svg)
self.svg.attrib["xmlns"] = self.ns_map["svg"]
self.ids = set()
self.idc = 0
self.name_mode = NameMode.Inkscape
self.actual_time = time
self.precomp_times = []
self._precomps = {}
self._assets = {}
self._current_layer = []
@property
def time(self):
time = self.actual_time
if self.precomp_times:
for pct in self.precomp_times:
time = pct.get_time_offset(time, self._current_layer[-1])
return time
def gen_id(self, prefix="id"):
while True:
self.idc += 1
id = "%s_%s" % (prefix, self.idc)
if id not in self.ids:
break
self.ids.add(id)
return id
def set_clean_id(self, dom, n):
idn = n.replace(" ", "_")
if self.id_re.match(idn) and idn not in self.ids:
self.ids.add(idn)
else:
idn = self.gen_id(dom.tag)
dom.attrib["id"] = idn
return idn
def set_id(self, dom, lottieobj, inkscape_qual=None, force=False):
n = getattr(lottieobj, "name", None)
if n is None or self.name_mode == NameMode.NoName:
if force:
id = self.gen_id(dom.tag)
dom.attrib["id"] = id
return id
return None
idn = self.set_clean_id(dom, n)
if inkscape_qual is None:
inkscape_qual = self.qualified("inkscape", "label")
if inkscape_qual:
dom.attrib[inkscape_qual] = n
return idn
def _on_animation(self, animation: objects.Animation):
self.svg.attrib["width"] = str(animation.width)
self.svg.attrib["height"] = str(animation.height)
self.svg.attrib["viewBox"] = "0 0 %s %s" % (animation.width, animation.height)
self.svg.attrib["version"] = "1.1"
self.set_id(self.svg, animation, self.qualified("sodipodi", "docname"))
self.defs = ElementTree.SubElement(self.svg, "defs")
if self.name_mode == NameMode.Inkscape:
self.svg.attrib[self.qualified("inkscape", "export-xdpi")] = "96"
self.svg.attrib[self.qualified("inkscape", "export-ydpi")] = "96"
namedview = ElementTree.SubElement(self.svg, self.qualified("sodipodi", "namedview"))
namedview.attrib[self.qualified("inkscape", "pagecheckerboard")] = "true"
namedview.attrib["borderlayer"] = "true"
namedview.attrib["bordercolor"] = "#666666"
namedview.attrib["pagecolor"] = "#ffffff"
self.svg.attrib["style"] = "fill: none; stroke: none"
self._current_layer = [animation]
return self.svg
def _mask_to_def(self, mask):
svgmask = ElementTree.SubElement(self.defs, "mask")
mask_id = self.gen_id()
svgmask.attrib["id"] = mask_id
svgmask.attrib["mask-type"] = "alpha"
path = ElementTree.SubElement(svgmask, "path")
path.attrib["d"] = self._bezier_to_d(mask.shape.get_value(self.time))
path.attrib["fill"] = "#fff"
path.attrib["fill-opacity"] = str(mask.opacity.get_value(self.time) / 100)
return mask_id
def _matte_source_to_def(self, layer_builder):
svgmask = ElementTree.SubElement(self.defs, "mask")
if not layer_builder.matte_id:
layer_builder.matte_id = self.gen_id()
svgmask.attrib["id"] = layer_builder.matte_id
matte_mode = layer_builder.matte_target.lottie.matte_mode
mask_type = "alpha"
if matte_mode == objects.MatteMode.Luma:
mask_type = "luminance"
svgmask.attrib["mask-type"] = mask_type
return svgmask
def _on_masks(self, masks):
if len(masks) == 1:
return self._mask_to_def(masks[0])
mask_ids = list(map(self._mask_to_def, masks))
mask_def = ElementTree.SubElement(self.defs, "mask")
mask_id = self.gen_id()
mask_def.attrib["id"] = mask_id
g = mask_def
for mid in mask_ids:
g = ElementTree.SubElement(g, "g")
g.attrib["mask"] = "url(#%s)" % mid
full = ElementTree.SubElement(g, "rect")
full.attrib["fill"] = "#fff"
full.attrib["width"] = self.svg.attrib["width"]
full.attrib["height"] = self.svg.attrib["height"]
full.attrib["x"] = "0"
full.attrib["y"] = "0"
return mask_id
def _on_layer(self, layer_builder, dom_parent):
lot = layer_builder.lottie
self._current_layer.append(lot)
if not self.precomp_times and (lot.in_point > self.time or lot.out_point < self.time):
self._current_layer.pop()
return None
if layer_builder.matte_target:
dom_parent = self._matte_source_to_def(layer_builder)
g = self.group_from_lottie(lot, dom_parent, True)
if lot.masks:
g.attrib["mask"] = "url(#%s)" % self._on_masks(lot.masks)
elif layer_builder.matte_source:
matte_id = layer_builder.matte_source.matte_id
if not matte_id:
matte_id = layer_builder.matte_source.matte_id = self.gen_id()
g.attrib["mask"] = "url(#%s)" % matte_id
if isinstance(lot, objects.PreCompLayer):
self.precomp_times.append(PrecompTime(lot))
for layer in self._precomps.get(lot.reference_id, []):
self.process_layer(layer, g)
self.precomp_times.pop()
elif isinstance(lot, objects.NullLayer):
g.attrib["opacity"] = "1"
elif isinstance(lot, objects.ImageLayer):
use = ElementTree.SubElement(g, "use")
use.attrib[self.qualified("xlink", "href")] = "#" + self._assets[lot.image_id]
elif isinstance(lot, objects.TextLayer):
self._on_text_layer(g, lot)
elif isinstance(lot, objects.SolidColorLayer):
rect = ElementTree.SubElement(g, "rect")
rect.attrib["width"] = str(lot.width)
rect.attrib["height"] = str(lot.height)
rect.attrib["fill"] = lot.color
if not lot.name:
g.attrib[self.qualified("inkscape", "label")] = lot.__class__.__name__
if layer_builder.shapegroup:
g.attrib["style"] = self.group_to_style(layer_builder.shapegroup)
self._split_stroke(layer_builder.shapegroup, g, dom_parent)
#if lot.hidden:
#g.attrib.setdefault("style", "")
#g.attrib["style"] += "display: none;"
return g
def _on_text_layer(self, g, lot):
text = ElementTree.SubElement(g, "text")
doc = lot.data.get_value(self.time)
if doc:
text.attrib["font-family"] = doc.font_family
text.attrib["font-size"] = str(doc.font_size)
if doc.line_height:
text.attrib["line-height"] = "%s%%" % doc.line_height
if doc.justify == objects.text.TextJustify.Left:
text.attrib["text-align"] = "start"
elif doc.justify == objects.text.TextJustify.Center:
text.attrib["text-align"] = "center"
elif doc.justify == objects.text.TextJustify.Right:
text.attrib["text-align"] = "end"
text.attrib["fill"] = color_to_css(doc.color)
text.text = doc.text
def _on_layer_end(self, out_layer):
self._current_layer.pop()
def _on_precomp(self, id, dom_parent, layers):
self._precomps[id] = layers
def _on_asset(self, asset):
if isinstance(asset, objects.assets.Image):
img = ElementTree.SubElement(self.defs, "image")
xmlid = self.set_clean_id(img, asset.id)
self._assets[asset.id] = xmlid
if asset.is_embedded:
url = asset.image
else:
url = asset.image_path + asset.image
img.attrib[self.qualified("xlink", "href")] = url
img.attrib["width"] = str(asset.width)
img.attrib["height"] = str(asset.height)
def _get_value(self, prop, default=NVector(0, 0)):
if prop:
v = prop.get_value(self.time)
else:
v = default
if v is None:
return default
if isinstance(v, NVector):
return v.clone()
return v
def set_transform(self, dom, transform, auto_orient=False):
mat = transform.to_matrix(self.time, auto_orient)
dom.attrib["transform"] = mat.to_css_2d()
if transform.opacity is not None:
op = transform.opacity.get_value(self.time)
if op != 100:
dom.attrib["opacity"] = str(op/100)
def _get_group_stroke(self, group):
style = {}
if group.stroke:
if isinstance(group.stroke, objects.GradientStroke):
style["stroke"] = "url(#%s)" % self.process_gradient(group.stroke)
else:
style["stroke"] = color_to_css(group.stroke.color.get_value(self.time))
style["stroke-opacity"] = group.stroke.opacity.get_value(self.time) / 100
style["stroke-width"] = group.stroke.width.get_value(self.time)
if group.stroke.miter_limit is not None:
style["stroke-miterlimit"] = group.stroke.miter_limit
if group.stroke.line_cap == objects.LineCap.Round:
style["stroke-linecap"] = "round"
elif group.stroke.line_cap == objects.LineCap.Butt:
style["stroke-linecap"] = "butt"
elif group.stroke.line_cap == objects.LineCap.Square:
style["stroke-linecap"] = "square"
if group.stroke.line_join == objects.LineJoin.Round:
style["stroke-linejoin"] = "round"
elif group.stroke.line_join == objects.LineJoin.Bevel:
style["stroke-linejoin"] = "bevel"
elif group.stroke.line_join == objects.LineJoin.Miter:
style["stroke-linejoin"] = "miter"
if group.stroke.dashes:
dasharray = []
last = 0
last_mode = objects.StrokeDashType.Dash
for dash in group.stroke.dashes:
if last_mode == dash.type:
last += dash.length.get_value(self.time)
else:
if last_mode != objects.StrokeDashType.Offset:
dasharray.append(str(last))
last = 0
last_mode = dash.type
style["stroke-dasharray"] = " ".join(dasharray)
return style
def _style_to_css(self, style):
return ";".join(map(
lambda x: ":".join(map(str, x)),
style.items()
))
def _split_stroke(self, group, fill_layer, out_parent):
if not group.stroke:# or group.stroke_above:
return
style = self._get_group_stroke(group)
if style.get("stroke-width", 0) <= 0 or style["stroke-opacity"] <= 0:
return
if group.stroke_above:
if fill_layer.attrib.get("style", ""):
fill_layer.attrib["style"] += ";"
else:
fill_layer.attrib["style"] = ""
fill_layer.attrib["style"] += self._style_to_css(style)
return fill_layer
g = ElementTree.Element("g")
self.set_clean_id(g, "stroke")
use = ElementTree.Element("use")
for i, e in enumerate(out_parent):
if e is fill_layer:
out_parent.insert(i, g)
out_parent.remove(fill_layer)
break
else:
return
g.append(use)
g.append(fill_layer)
use.attrib[self.qualified("xlink", "href")] = "#" + fill_layer.attrib["id"]
use.attrib["style"] = self._style_to_css(style)
return g
def group_to_style(self, group):
style = {}
if group.fill:
style["fill-opacity"] = group.fill.opacity.get_value(self.time) / 100
if isinstance(group.fill, objects.GradientFill):
style["fill"] = "url(#%s)" % self.process_gradient(group.fill)
else:
style["fill"] = color_to_css(group.fill.color.get_value(self.time))
if group.fill.fill_rule:
style["fill-rule"] = "evenodd" if group.fill.fill_rule == objects.FillRule.EvenOdd else "nonzero"
if group.lottie.hidden:
style["display"] = "none"
#if group.stroke_above:
#style.update(self._get_group_stroke(group))
return self._style_to_css(style)
def process_gradient(self, gradient):
spos = gradient.start_point.get_value(self.time)
epos = gradient.end_point.get_value(self.time)
if gradient.gradient_type == objects.GradientType.Linear:
dom = ElementTree.SubElement(self.defs, "linearGradient")
dom.attrib["x1"] = str(spos[0])
dom.attrib["y1"] = str(spos[1])
dom.attrib["x2"] = str(epos[0])
dom.attrib["y2"] = str(epos[1])
elif gradient.gradient_type == objects.GradientType.Radial:
dom = ElementTree.SubElement(self.defs, "radialGradient")
dom.attrib["cx"] = str(spos[0])
dom.attrib["cy"] = str(spos[1])
dom.attrib["r"] = str((epos-spos).length)
a = gradient.highlight_angle.get_value(self.time) * math.pi / 180
l = gradient.highlight_length.get_value(self.time)
dom.attrib["fx"] = str(spos[0] + math.cos(a) * l)
dom.attrib["fy"] = str(spos[1] + math.sin(a) * l)
id = self.set_id(dom, gradient, force=True)
dom.attrib["gradientUnits"] = "userSpaceOnUse"
for off, color in gradient.colors.stops_at(self.time):
stop = ElementTree.SubElement(dom, "stop")
stop.attrib["offset"] = "%s%%" % (off * 100)
stop.attrib["stop-color"] = color_to_css(color[:3])
if len(color) > 3:
stop.attrib["stop-opacity"] = str(color[3])
return id
def group_from_lottie(self, lottie, dom_parent, layer):
g = ElementTree.SubElement(dom_parent, "g")
if layer and self.name_mode == NameMode.Inkscape:
g.attrib[self.qualified("inkscape", "groupmode")] = "layer"
self.set_id(g, lottie, force=True)
self.set_transform(g, lottie.transform, getattr(lottie, "auto_orient", False))
return g
def _on_shapegroup(self, group, dom_parent):
if group.empty():
return
if len(group.children) == 1 and isinstance(group.children[0], restructure.RestructuredPathMerger):
path = self.build_path(group.paths.paths, dom_parent)
self.set_id(path, group.paths.paths[0], force=True)
path.attrib["style"] = self.group_to_style(group)
self.set_transform(path, group.lottie.transform)
return self._split_stroke(group, path, dom_parent)
g = self.group_from_lottie(group.lottie, dom_parent, group.layer)
g.attrib["style"] = self.group_to_style(group)
self.shapegroup_process_children(group, g)
return self._split_stroke(group, g, dom_parent)
def _on_merged_path(self, shape, shapegroup, out_parent):
path = self.build_path(shape.paths, out_parent)
self.set_id(path, shape.paths[0])
path.attrib["style"] = self.group_to_style(shapegroup)
#self._split_stroke(shapegroup, path, out_parent)
return path
def _on_shape(self, shape, shapegroup, out_parent):
if isinstance(shape, objects.Rect):
svgshape = self.build_rect(shape, out_parent)
elif isinstance(shape, objects.Ellipse):
svgshape = self.build_ellipse(shape, out_parent)
elif isinstance(shape, objects.Star):
svgshape = self.build_path([shape.to_bezier()], out_parent)
elif isinstance(shape, objects.Path):
svgshape = self.build_path([shape], out_parent)
elif has_font and isinstance(shape, font.FontShape):
svgshape = self.build_text(shape, out_parent)
else:
return
self.set_id(svgshape, shape, force=True)
if "style" not in svgshape.attrib:
svgshape.attrib["style"] = ""
svgshape.attrib["style"] += self.group_to_style(shapegroup)
#self._split_stroke(shapegroup, svgshape, out_parent)
if shape.hidden:
svgshape.attrib["style"] += "display: none;"
return svgshape
def build_rect(self, shape, parent):
rect = ElementTree.SubElement(parent, "rect")
size = shape.size.get_value(self.time)
pos = shape.position.get_value(self.time)
rect.attrib["width"] = str(size[0])
rect.attrib["height"] = str(size[1])
rect.attrib["x"] = str(pos[0] - size[0] / 2)
rect.attrib["y"] = str(pos[1] - size[1] / 2)
rect.attrib["rx"] = str(shape.rounded.get_value(self.time))
return rect
def build_ellipse(self, shape, parent):
ellipse = ElementTree.SubElement(parent, "ellipse")
size = shape.size.get_value(self.time)
pos = shape.position.get_value(self.time)
ellipse.attrib["rx"] = str(size[0] / 2)
ellipse.attrib["ry"] = str(size[1] / 2)
ellipse.attrib["cx"] = str(pos[0])
ellipse.attrib["cy"] = str(pos[1])
return ellipse
def build_path(self, shapes, parent):
path = ElementTree.SubElement(parent, "path")
d = ""
for shape in shapes:
bez = shape.shape.get_value(self.time)
if isinstance(bez, list):
bez = bez[0]
if not bez.vertices:
continue
if d:
d += "\n"
d += self._bezier_to_d(bez)
path.attrib["d"] = d
return path
def _bezier_tangent(self, tangent):
_tangent_threshold = 0.5
if tangent.length < _tangent_threshold:
return NVector(0, 0)
return tangent
def _bezier_to_d(self, bez):
d = "M %s,%s " % tuple(bez.vertices[0].components[:2])
for i in range(1, len(bez.vertices)):
qfrom = bez.vertices[i-1]
h1 = self._bezier_tangent(bez.out_tangents[i-1]) + qfrom
qto = bez.vertices[i]
h2 = self._bezier_tangent(bez.in_tangents[i]) + qto
d += "C %s,%s %s,%s %s,%s " % (
h1[0], h1[1],
h2[0], h2[1],
qto[0], qto[1],
)
if bez.closed:
qfrom = bez.vertices[-1]
h1 = self._bezier_tangent(bez.out_tangents[-1]) + qfrom
qto = bez.vertices[0]
h2 = self._bezier_tangent(bez.in_tangents[0]) + qto
d += "C %s,%s %s,%s %s,%s Z" % (
h1[0], h1[1],
h2[0], h2[1],
qto[0], qto[1],
)
return d
def _on_shape_modifier(self, shape, shapegroup, out_parent):
if isinstance(shape.lottie, objects.Repeater):
svgshape = self.build_repeater(shape.lottie, shape.child, shapegroup, out_parent)
elif isinstance(shape.lottie, objects.RoundedCorners):
svgshape = self.build_rouded_corners(shape.lottie, shape.child, shapegroup, out_parent)
elif isinstance(shape.lottie, objects.Trim):
svgshape = self.build_trim_path(shape.lottie, shape.child, shapegroup, out_parent)
else:
return self.shapegroup_process_child(shape.child, shapegroup, out_parent)
return svgshape
def build_repeater(self, shape, child, shapegroup, out_parent):
original = self.shapegroup_process_child(child, shapegroup, out_parent)
if not original:
return
ncopies = int(round(shape.copies.get_value(self.time)))
if ncopies == 1:
return
out_parent.remove(original)
g = ElementTree.SubElement(out_parent, "g")
self.set_clean_id(g, "repeater")
for copy in range(ncopies-1):
use = ElementTree.SubElement(g, "use")
use.attrib[self.qualified("xlink", "href")] = "#" + original.attrib["id"]
orig_wrapper = ElementTree.SubElement(g, "g")
orig_wrapper.append(original)
transform = objects.Transform()
so = shape.transform.start_opacity.get_value(self.time)
eo = shape.transform.end_opacity.get_value(self.time)
position = shape.transform.position.get_value(self.time)
rotation = shape.transform.rotation.get_value(self.time)
anchor_point = shape.transform.anchor_point.get_value(self.time)
for i in range(ncopies-1, -1, -1):
of = i / (ncopies-1)
transform.opacity.value = so * of + eo * (1 - of)
self.set_transform(g[i], transform)
transform.position.value += position
transform.rotation.value += rotation
transform.anchor_point.value += anchor_point
return g
def build_rouded_corners(self, shape, child, shapegroup, out_parent):
round_amount = shape.radius.get_value(self.time)
return self._modifier_process(child, shapegroup, out_parent, self._build_rouded_corners_shape, round_amount)
def _build_rouded_corners_shape(self, shape, round_amount):
if not isinstance(shape, objects.Shape):
return [shape]
path = shape.to_bezier()
bezier = path.shape.get_value(self.time).rounded(round_amount)
path.shape.clear_animation(bezier)
return [path]
def build_trim_path(self, shape, child, shapegroup, out_parent):
start = max(0, min(1, shape.start.get_value(self.time) / 100))
end = max(0, min(1, shape.end.get_value(self.time) / 100))
offset = shape.offset.get_value(self.time) / 360 % 1
multidata = {}
length = 0
if shape.multiple == objects.TrimMultipleShapes.Individually:
for visishape in reversed(list(self._modifier_foreach_shape(child))):
bez = visishape.to_bezier().shape.get_value(self.time)
local_length = bez.rough_length()
multidata[visishape] = (bez, length, local_length)
length += local_length
return self._modifier_process(
child, shapegroup, out_parent, self._build_trim_path_shape,
start+offset, end+offset, multidata, length
)
def _modifier_foreach_shape(self, shape):
if isinstance(shape, restructure.RestructuredShapeGroup):
for child in shape.children:
for chsh in self._modifier_foreach_shape(child):
yield chsh
elif isinstance(shape, restructure.RestructuredPathMerger):
for p in shape.paths:
yield p
elif isinstance(shape, objects.Shape):
yield shape
def _modifier_process(self, child, shapegroup, out_parent, callback, *args):
children = self._modifier_process_child(child, shapegroup, out_parent, callback, *args)
return [self.shapegroup_process_child(ch, shapegroup, out_parent) for ch in children]
def _trim_offlocal(self, t, local_start, local_length, total_length):
gt = (t * total_length - local_start) / local_length
return max(0, min(1, gt))
def _build_trim_path_shape(self, shape, start, end, multidata, total_length):
if not isinstance(shape, objects.Shape):
return [shape]
if multidata:
bezier, local_start, local_length = multidata[shape]
if end > 1:
lstart = self._trim_offlocal(start, local_start, local_length, total_length)
lend = self._trim_offlocal(end-1, local_start, local_length, total_length)
out = []
if lstart < 1:
out.append(objects.Path(bezier.segment(lstart, 1)))
if lend > 0:
out.append(objects.Path(bezier.segment(0, lend)))
return out
lstart = self._trim_offlocal(start, local_start, local_length, total_length)
lend = self._trim_offlocal(end, local_start, local_length, total_length)
if lend <= 0 or lstart >= 1:
return []
if lstart <= 0 and lend >= 1:
return [objects.Path(bezier)]
seg = bezier.segment(lstart, lend)
return [objects.Path(seg)]
path = shape.to_bezier()
bezier = path.shape.get_value(self.time)
if end > 1:
bez1 = bezier.segment(start, 1)
bez2 = bezier.segment(0, end-1)
return [objects.Path(bez1), objects.Path(bez2)]
else:
seg = bezier.segment(start, end)
return [objects.Path(seg)]
def _modifier_process_children(self, shapegroup, out_parent, callback, *args):
children = []
for shape in shapegroup.children:
children.extend(self._modifier_process_child(shape, shapegroup, out_parent, callback, *args))
shapegroup.children = children
def _modifier_process_child(self, shape, shapegroup, out_parent, callback, *args):
if isinstance(shape, restructure.RestructuredShapeGroup):
self._modifier_process_children(shape, out_parent, callback, *args)
return [shape]
elif isinstance(shape, restructure.RestructuredPathMerger):
paths = []
for p in shape.paths:
paths.extend(callback(p, *args))
shape.paths = paths
if paths:
return [shape]
return []
else:
return callback(shape, *args)
def _custom_object_supported(self, shape):
if has_font and isinstance(shape, font.FontShape):
return True
return False
def build_text(self, shape, parent):
text = ElementTree.SubElement(parent, "text")
if "family" in shape.query:
text.attrib["font-family"] = shape.query["family"]
if "weight" in shape.query:
text.attrib["font-weight"] = str(shape.query.weight_to_css())
slant = int(shape.query.get("slant", 0))
if slant > 0 and slant < 110:
text.attrib["font-style"] = "italic"
elif slant >= 110:
text.attrib["font-style"] = "oblique"
text.attrib["font-size"] = str(shape.size)
text.attrib["white-space"] = "pre"
pos = shape.style.position
text.attrib["x"] = str(pos.x)
text.attrib["y"] = str(pos.y)
text.text = shape.text
return text
def color_to_css(color):
#if len(color) == 4:
#return ("rgba(%s, %s, %s" % tuple(map(lambda c: int(round(c*255)), color[:3]))) + ", %s)" % color[3]
return "rgb(%s, %s, %s)" % tuple(map(lambda c: int(round(c*255)), color[:3]))
def to_svg(animation, time):
builder = SvgBuilder(time)
builder.process(animation)
return builder.dom
+38
View File
@@ -0,0 +1,38 @@
import enum
from xml.etree import ElementTree
class SvgHandler:
ns_map = {
"dc": "http://purl.org/dc/elements/1.1/",
"cc": "http://creativecommons.org/ns#",
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
"svg": "http://www.w3.org/2000/svg",
"sodipodi": "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd",
"inkscape": "http://www.inkscape.org/namespaces/inkscape",
"xlink": "http://www.w3.org/1999/xlink",
}
def init_etree(self):
for n, u in self.ns_map.items():
ElementTree.register_namespace(n, u)
def qualified(self, ns, name):
return "{%s}%s" % (self.ns_map[ns], name)
def simplified(self, name):
for k, v in self.ns_map.items():
name = name.replace("{%s}" % v, k+":")
return name
def unqualified(self, name):
return name.split("}")[-1]
def __init__(self):
self.init_etree()
class NameMode(enum.Enum):
NoName = 0
Id = 1
Inkscape = 2
File diff suppressed because it is too large Load Diff
+211
View File
@@ -0,0 +1,211 @@
color_table = {
"aliceblue": [0.9411764705882353, 0.9725490196078431, 1.0, 1],
"antiquewhite": [0.9803921568627451, 0.9215686274509803, 0.8431372549019608, 1],
"aqua": [0.0, 1.0, 1.0, 1],
"aquamarine": [0.4980392156862745, 1.0, 0.8313725490196079, 1],
"azure": [0.9411764705882353, 1.0, 1.0, 1],
"beige": [0.9607843137254902, 0.9607843137254902, 0.8627450980392157, 1],
"bisque": [1.0, 0.8941176470588236, 0.7686274509803922, 1],
"black": [0.0, 0.0, 0.0, 1],
"blanchedalmond": [1.0, 0.9215686274509803, 0.803921568627451, 1],
"blue": [0.0, 0.0, 1.0, 1],
"blueviolet": [0.5411764705882353, 0.16862745098039217, 0.8862745098039215, 1],
"brown": [0.6470588235294118, 0.16470588235294117, 0.16470588235294117, 1],
"burlywood": [0.8705882352941177, 0.7215686274509804, 0.5294117647058824, 1],
"cadetblue": [0.37254901960784315, 0.6196078431372549, 0.6274509803921569, 1],
"chartreuse": [0.4980392156862745, 1.0, 0.0, 1],
"chocolate": [0.8235294117647058, 0.4117647058823529, 0.11764705882352941, 1],
"coral": [1.0, 0.4980392156862745, 0.3137254901960784, 1],
"cornflowerblue": [0.39215686274509803, 0.5843137254901961, 0.9294117647058824, 1],
"cornsilk": [1.0, 0.9725490196078431, 0.8627450980392157, 1],
"crimson": [0.8627450980392157, 0.0784313725490196, 0.23529411764705882, 1],
"cyan": [0.0, 1.0, 1.0, 1],
"darkblue": [0.0, 0.0, 0.5450980392156862, 1],
"darkcyan": [0.0, 0.5450980392156862, 0.5450980392156862, 1],
"darkgoldenrod": [0.7215686274509804, 0.5254901960784314, 0.043137254901960784, 1],
"darkgray": [0.6627450980392157, 0.6627450980392157, 0.6627450980392157, 1],
"darkgreen": [0.0, 0.39215686274509803, 0.0, 1],
"darkgrey": [0.6627450980392157, 0.6627450980392157, 0.6627450980392157, 1],
"darkkhaki": [0.7411764705882353, 0.7176470588235294, 0.4196078431372549, 1],
"darkmagenta": [0.5450980392156862, 0.0, 0.5450980392156862, 1],
"darkolivegreen": [0.3333333333333333, 0.4196078431372549, 0.1843137254901961, 1],
"darkorange": [1.0, 0.5490196078431373, 0.0, 1],
"darkorchid": [0.6, 0.19607843137254902, 0.8, 1],
"darkred": [0.5450980392156862, 0.0, 0.0, 1],
"darksalmon": [0.9137254901960784, 0.5882352941176471, 0.47843137254901963, 1],
"darkseagreen": [0.5607843137254902, 0.7372549019607844, 0.5607843137254902, 1],
"darkslateblue": [0.2823529411764706, 0.23921568627450981, 0.5450980392156862, 1],
"darkslategray": [0.1843137254901961, 0.30980392156862746, 0.30980392156862746, 1],
"darkslategrey": [0.1843137254901961, 0.30980392156862746, 0.30980392156862746, 1],
"darkturquoise": [0.0, 0.807843137254902, 0.8196078431372549, 1],
"darkviolet": [0.5803921568627451, 0.0, 0.8274509803921568, 1],
"deeppink": [1.0, 0.0784313725490196, 0.5764705882352941, 1],
"deepskyblue": [0.0, 0.7490196078431373, 1.0, 1],
"dimgray": [0.4117647058823529, 0.4117647058823529, 0.4117647058823529, 1],
"dimgrey": [0.4117647058823529, 0.4117647058823529, 0.4117647058823529, 1],
"dodgerblue": [0.11764705882352941, 0.5647058823529412, 1.0, 1],
"firebrick": [0.6980392156862745, 0.13333333333333333, 0.13333333333333333, 1],
"floralwhite": [1.0, 0.9803921568627451, 0.9411764705882353, 1],
"forestgreen": [0.13333333333333333, 0.5450980392156862, 0.13333333333333333, 1],
"fuchsia": [1.0, 0.0, 1.0, 1],
"gainsboro": [0.8627450980392157, 0.8627450980392157, 0.8627450980392157, 1],
"ghostwhite": [0.9725490196078431, 0.9725490196078431, 1.0, 1],
"gold": [1.0, 0.8431372549019608, 0.0, 1],
"goldenrod": [0.8549019607843137, 0.6470588235294118, 0.12549019607843137, 1],
"gray": [0.5019607843137255, 0.5019607843137255, 0.5019607843137255, 1],
"green": [0.0, 0.5019607843137255, 0.0, 1],
"greenyellow": [0.6784313725490196, 1.0, 0.1843137254901961, 1],
"grey": [0.5019607843137255, 0.5019607843137255, 0.5019607843137255, 1],
"honeydew": [0.9411764705882353, 1.0, 0.9411764705882353, 1],
"hotpink": [1.0, 0.4117647058823529, 0.7058823529411765, 1],
"indianred": [0.803921568627451, 0.3607843137254902, 0.3607843137254902, 1],
"indigo": [0.29411764705882354, 0.0, 0.5098039215686274, 1],
"ivory": [1.0, 1.0, 0.9411764705882353, 1],
"khaki": [0.9411764705882353, 0.9019607843137255, 0.5490196078431373, 1],
"lavender": [0.9019607843137255, 0.9019607843137255, 0.9803921568627451, 1],
"lavenderblush": [1.0, 0.9411764705882353, 0.9607843137254902, 1],
"lawngreen": [0.48627450980392156, 0.9882352941176471, 0.0, 1],
"lemonchiffon": [1.0, 0.9803921568627451, 0.803921568627451, 1],
"lightblue": [0.6784313725490196, 0.8470588235294118, 0.9019607843137255, 1],
"lightcoral": [0.9411764705882353, 0.5019607843137255, 0.5019607843137255, 1],
"lightcyan": [0.8784313725490196, 1.0, 1.0, 1],
"lightgoldenrodyellow": [0.9803921568627451, 0.9803921568627451, 0.8235294117647058, 1],
"lightgray": [0.8274509803921568, 0.8274509803921568, 0.8274509803921568, 1],
"lightgreen": [0.5647058823529412, 0.9333333333333333, 0.5647058823529412, 1],
"lightgrey": [0.8274509803921568, 0.8274509803921568, 0.8274509803921568, 1],
"lightpink": [1.0, 0.7137254901960784, 0.7568627450980392, 1],
"lightsalmon": [1.0, 0.6274509803921569, 0.47843137254901963, 1],
"lightseagreen": [0.12549019607843137, 0.6980392156862745, 0.6666666666666666, 1],
"lightskyblue": [0.5294117647058824, 0.807843137254902, 0.9803921568627451, 1],
"lightslategray": [0.4666666666666667, 0.5333333333333333, 0.6, 1],
"lightslategrey": [0.4666666666666667, 0.5333333333333333, 0.6, 1],
"lightsteelblue": [0.6901960784313725, 0.7686274509803922, 0.8705882352941177, 1],
"lightyellow": [1.0, 1.0, 0.8784313725490196, 1],
"lime": [0.0, 1.0, 0.0, 1],
"limegreen": [0.19607843137254902, 0.803921568627451, 0.19607843137254902, 1],
"linen": [0.9803921568627451, 0.9411764705882353, 0.9019607843137255, 1],
"magenta": [1.0, 0.0, 1.0, 1],
"maroon": [0.5019607843137255, 0.0, 0.0, 1],
"mediumaquamarine": [0.4, 0.803921568627451, 0.6666666666666666, 1],
"mediumblue": [0.0, 0.0, 0.803921568627451, 1],
"mediumorchid": [0.7294117647058823, 0.3333333333333333, 0.8274509803921568, 1],
"mediumpurple": [0.5764705882352941, 0.4392156862745098, 0.8588235294117647, 1],
"mediumseagreen": [0.23529411764705882, 0.7019607843137254, 0.44313725490196076, 1],
"mediumslateblue": [0.4823529411764706, 0.40784313725490196, 0.9333333333333333, 1],
"mediumspringgreen": [0.0, 0.9803921568627451, 0.6039215686274509, 1],
"mediumturquoise": [0.2823529411764706, 0.8196078431372549, 0.8, 1],
"mediumvioletred": [0.7803921568627451, 0.08235294117647059, 0.5215686274509804, 1],
"midnightblue": [0.09803921568627451, 0.09803921568627451, 0.4392156862745098, 1],
"mintcream": [0.9607843137254902, 1.0, 0.9803921568627451, 1],
"mistyrose": [1.0, 0.8941176470588236, 0.8823529411764706, 1],
"moccasin": [1.0, 0.8941176470588236, 0.7098039215686275, 1],
"navajowhite": [1.0, 0.8705882352941177, 0.6784313725490196, 1],
"navy": [0.0, 0.0, 0.5019607843137255, 1],
"oldlace": [0.9921568627450981, 0.9607843137254902, 0.9019607843137255, 1],
"olive": [0.5019607843137255, 0.5019607843137255, 0.0, 1],
"olivedrab": [0.4196078431372549, 0.5568627450980392, 0.13725490196078433, 1],
"orange": [1.0, 0.6470588235294118, 0.0, 1],
"orangered": [1.0, 0.27058823529411763, 0.0, 1],
"orchid": [0.8549019607843137, 0.4392156862745098, 0.8392156862745098, 1],
"palegoldenrod": [0.9333333333333333, 0.9098039215686274, 0.6666666666666666, 1],
"palegreen": [0.596078431372549, 0.984313725490196, 0.596078431372549, 1],
"paleturquoise": [0.6862745098039216, 0.9333333333333333, 0.9333333333333333, 1],
"palevioletred": [0.8588235294117647, 0.4392156862745098, 0.5764705882352941, 1],
"papayawhip": [1.0, 0.9372549019607843, 0.8352941176470589, 1],
"peachpuff": [1.0, 0.8549019607843137, 0.7254901960784313, 1],
"peru": [0.803921568627451, 0.5215686274509804, 0.24705882352941178, 1],
"pink": [1.0, 0.7529411764705882, 0.796078431372549, 1],
"plum": [0.8666666666666667, 0.6274509803921569, 0.8666666666666667, 1],
"powderblue": [0.6901960784313725, 0.8784313725490196, 0.9019607843137255, 1],
"purple": [0.5019607843137255, 0.0, 0.5019607843137255, 1],
"red": [1.0, 0.0, 0.0, 1],
"rosybrown": [0.7372549019607844, 0.5607843137254902, 0.5607843137254902, 1],
"royalblue": [0.2549019607843137, 0.4117647058823529, 0.8823529411764706, 1],
"saddlebrown": [0.5450980392156862, 0.27058823529411763, 0.07450980392156863, 1],
"salmon": [0.9803921568627451, 0.5019607843137255, 0.4470588235294118, 1],
"sandybrown": [0.9568627450980393, 0.6431372549019608, 0.3764705882352941, 1],
"seagreen": [0.1803921568627451, 0.5450980392156862, 0.3411764705882353, 1],
"seashell": [1.0, 0.9607843137254902, 0.9333333333333333, 1],
"sienna": [0.6274509803921569, 0.3215686274509804, 0.17647058823529413, 1],
"silver": [0.7529411764705882, 0.7529411764705882, 0.7529411764705882, 1],
"skyblue": [0.5294117647058824, 0.807843137254902, 0.9215686274509803, 1],
"slateblue": [0.41568627450980394, 0.35294117647058826, 0.803921568627451, 1],
"slategray": [0.4392156862745098, 0.5019607843137255, 0.5647058823529412, 1],
"slategrey": [0.4392156862745098, 0.5019607843137255, 0.5647058823529412, 1],
"snow": [1.0, 0.9803921568627451, 0.9803921568627451, 1],
"springgreen": [0.0, 1.0, 0.4980392156862745, 1],
"steelblue": [0.27450980392156865, 0.5098039215686274, 0.7058823529411765, 1],
"tan": [0.8235294117647058, 0.7058823529411765, 0.5490196078431373, 1],
"teal": [0.0, 0.5019607843137255, 0.5019607843137255, 1],
"thistle": [0.8470588235294118, 0.7490196078431373, 0.8470588235294118, 1],
"tomato": [1.0, 0.38823529411764707, 0.2784313725490196, 1],
"turquoise": [0.25098039215686274, 0.8784313725490196, 0.8156862745098039, 1],
"violet": [0.9333333333333333, 0.5098039215686274, 0.9333333333333333, 1],
"wheat": [0.9607843137254902, 0.8705882352941177, 0.7019607843137254, 1],
"white": [1.0, 1.0, 1.0, 1],
"whitesmoke": [0.9607843137254902, 0.9607843137254902, 0.9607843137254902, 1],
"yellow": [1.0, 1.0, 0.0, 1],
"yellowgreen": [0.6039215686274509, 0.803921568627451, 0.19607843137254902, 1],
}
css_atrrs = {
"fill",
"alignment-baseline",
"baseline-shift",
"clip-path",
"clip-rule",
"color",
"color-interpolation",
"color-interpolation-filters",
"color-rendering",
"cursor",
"direction",
"display",
"dominant-baseline",
"fill-opacity",
"fill-rule",
"filter",
"flood-color",
"flood-opacity",
"font-family",
"font-size",
"font-size-adjust",
"font-stretch",
"font-style",
"font-variant",
"font-weight",
"glyph-orientation-horizontal",
"glyph-orientation-vertical",
"image-rendering",
"letter-spacing",
"lighting-color",
"marker-end",
"marker-mid",
"marker-start",
"mask",
"opacity",
"overflow",
"paint-order",
"pointer-events",
"shape-rendering",
"stop-color",
"stop-opacity",
"stroke",
"stroke-dasharray",
"stroke-dashoffset",
"stroke-linecap",
"stroke-linejoin",
"stroke-miterlimit",
"stroke-opacity",
"stroke-width",
"text-anchor",
"text-decoration",
"text-overflow",
"text-rendering",
"unicode-bidi",
"vector-effect",
"visibility",
"white-space",
"word-spacing",
"writing-mode"
}
+41
View File
@@ -0,0 +1,41 @@
import io
import json
import gzip
from ..objects import Animation
def parse_tgs_json(file):
"""!
Reads both tgs and lottie files, returns the json structure
"""
return open_maybe_gzipped(file, json.load)
def open_maybe_gzipped(file, on_open):
if isinstance(file, str):
with open(file, "r") as fileobj:
return open_maybe_gzipped(fileobj, on_open)
if isinstance(file, io.TextIOBase):
binfile = file.buffer
else:
binfile = file
mn = binfile.read(2)
binfile.seek(0)
if mn == b'\x1f\x8b': # gzip magic number
final_file = gzip.open(binfile, "rb")
elif isinstance(file, io.TextIOBase):
final_file = file
else:
final_file = io.TextIOWrapper(file)
return on_open(final_file)
def parse_tgs(filename):
"""!
Reads both tgs and lottie files
"""
lottie = parse_tgs_json(filename)
return Animation.load(lottie)
+7
View File
@@ -0,0 +1,7 @@
__all__ = ["animation", "ellipse", "ik", "linediff", "restructure", "script", "stripper"]
try:
from . import font
__all__ += ["font"]
except ImportError:
pass
+544
View File
@@ -0,0 +1,544 @@
import random
import math
from ..nvector import NVector
from ..objects.shapes import Path
from .. import objects
from ..objects import easing
from ..objects import properties
def shake(position_prop, x_radius, y_radius, start_time, end_time, n_frames, interp=easing.Linear()):
if not isinstance(position_prop, list):
position_prop = [position_prop]
n_frames = int(round(n_frames))
frame_time = (end_time - start_time) / n_frames
startpoints = list(map(
lambda pp: pp.get_value(start_time),
position_prop
))
for i in range(n_frames):
x = (random.random() * 2 - 1) * x_radius
y = (random.random() * 2 - 1) * y_radius
for pp, start in zip(position_prop, startpoints):
px = start[0] + x
py = start[1] + y
pp.add_keyframe(start_time + i * frame_time, NVector(px, py), interp)
for pp, start in zip(position_prop, startpoints):
pp.add_keyframe(end_time, start, interp)
def rot_shake(rotation_prop, angles, start_time, end_time, n_frames):
frame_time = (end_time - start_time) / n_frames
start = rotation_prop.get_value(start_time)
for i in range(0, n_frames):
a = angles[i % len(angles)] * math.sin(i/n_frames * math.pi)
rotation_prop.add_keyframe(start_time + i * frame_time, start + a)
rotation_prop.add_keyframe(end_time, start)
def spring_pull(position_prop, point, start_time, end_time, falloff=15, oscillations=7):
start = position_prop.get_value(start_time)
d = start-point
delta = (end_time - start_time) / oscillations
for i in range(oscillations):
time_x = i / oscillations
factor = math.cos(time_x * math.pi * oscillations) * (1-time_x**(1/falloff))
p = point + d * factor
position_prop.add_keyframe(start_time + delta * i, p)
position_prop.add_keyframe(end_time, point)
def follow_path(position_prop, bezier, start_time, end_time, n_keyframes,
reverse=False, offset=NVector(0, 0), start_t=0, rotation_prop=None, rotation_offset=0):
delta = (end_time - start_time) / (n_keyframes-1)
fact = start_t
factd = 1 / (n_keyframes-1)
if rotation_prop:
start_rot = rotation_prop.get_value(start_time) if rotation_offset is None else rotation_offset
for i in range(n_keyframes):
time = start_time + i * delta
if fact > 1 + factd/2:
fact -= 1
if time != start_time:
easing.Jump()(position_prop.keyframes[-1])
if rotation_prop:
easing.Jump()(rotation_prop.keyframes[-1])
f = 1 - fact if reverse else fact
position_prop.add_keyframe(time, bezier.point_at(f)+offset)
if rotation_prop:
rotation_prop.add_keyframe(time, bezier.tangent_angle_at(f) / math.pi * 180 + start_rot)
fact += factd
def generate_path_appear(bezier, appear_start, appear_end, n_keyframes, reverse=False):
obj = Path()
beziers = []
maxp = 0
time_delta = (appear_end - appear_start) / n_keyframes
for i in range(n_keyframes+1):
time = appear_start + i * time_delta
t2 = (time - appear_start) / (appear_end - appear_start)
if reverse:
t2 = 1 - t2
segment = bezier.segment(t2, 1)
segment.reverse()
else:
segment = bezier.segment(0, t2)
beziers.append(segment)
if len(segment.vertices) > maxp:
maxp = len(segment.vertices)
obj.shape.add_keyframe(time, segment)
for segment in beziers:
deltap = maxp - len(segment.vertices)
if deltap > 0:
segment.vertices += [segment.vertices[-1]] * deltap
segment.in_tangents += [NVector(0, 0)] * deltap
segment.out_tangents += [NVector(0, 0)] * deltap
return obj
def generate_path_disappear(bezier, disappear_start, disappear_end, n_keyframes, reverse=False):
obj = Path()
beziers = []
maxp = 0
time_delta = (disappear_end - disappear_start) / n_keyframes
for i in range(n_keyframes+1):
time = disappear_start + i * time_delta
t1 = (time - disappear_start) / (disappear_end - disappear_start)
if reverse:
t1 = 1 - t1
segment = bezier.segment(0, t1)
else:
segment = bezier.segment(1, t1)
segment.reverse()
beziers.append(segment)
if len(segment.vertices) > maxp:
maxp = len(segment.vertices)
obj.shape.add_keyframe(time, segment)
for segment in beziers:
deltap = maxp - len(segment.vertices)
if deltap > 0:
segment.vertices += [segment.vertices[-1]] * deltap
segment.in_tangents += [NVector(0, 0)] * deltap
segment.out_tangents += [NVector(0, 0)] * deltap
return obj
def generate_path_segment(bezier, appear_start, appear_end, disappear_start, disappear_end, n_keyframes, reverse=False):
obj = Path()
beziers = []
maxp = 0
# HACK: For some reson reversed works better
if not reverse:
bezier.reverse()
time_delta = (appear_end - appear_start) / n_keyframes
for i in range(n_keyframes+1):
time = appear_start + i * time_delta
t1 = (time - disappear_start) / (disappear_end - disappear_start)
t2 = (time - appear_start) / (appear_end - appear_start)
t1 = max(0, min(1, t1))
t2 = max(0, min(1, t2))
#if reverse:
if True:
t1 = 1 - t1
t2 = 1 - t2
segment = bezier.segment(t2, t1)
segment.reverse()
#else:
#segment = bezier.segment(t1, t2)
#segment.reverse()
beziers.append(segment)
if len(segment.vertices) > maxp:
maxp = len(segment.vertices)
obj.shape.add_keyframe(time, segment)
for segment in beziers:
deltap = maxp - len(segment.vertices)
if deltap > 0:
segment.split_self_chunks(deltap+1)
# HACK: Restore
if not reverse:
bezier.reverse()
return obj
class PointDisplacer:
def __init__(self, time_start, time_end, n_frames):
"""!
@param time_start When the animation shall start
@param time_end When the animation shall end
@param n_frames Number of frames in the animation
"""
## When the animation shall start
self.time_start = time_start
## When the animation shall end
self.time_end = time_end
## Number of frames in the animation
self.n_frames = n_frames
## Length of a frame
self.time_delta = (time_end - time_start) / n_frames
def animate_point(self, prop):
startpos = prop.get_value(self.time_start)
for f in range(self.n_frames+1):
p = self._on_displace(startpos, f)
prop.add_keyframe(self.frame_time(f), startpos+p)
def _on_displace(self, startpos, f):
raise NotImplementedError()
def animate_bezier(self, prop):
initial = prop.get_value(self.time_start)
for f in range(self.n_frames+1):
bezier = objects.Bezier()
bezier.closed = initial.closed
for pi in range(len(initial.vertices)):
startpos = initial.vertices[pi]
dp = self._on_displace(startpos, f)
t1sp = initial.in_tangents[pi] + startpos
t1fin = initial.in_tangents[pi] + self._on_displace(t1sp, f) - dp
t2sp = initial.out_tangents[pi] + startpos
t2fin = initial.out_tangents[pi] + self._on_displace(t2sp, f) - dp
bezier.add_point(dp + startpos, t1fin, t2fin)
prop.add_keyframe(self.frame_time(f), bezier)
def frame_time(self, f):
return f * self.time_delta + self.time_start
def _init_lerp(self, val_from, val_to, easing):
self._kf = properties.OffsetKeyframe(0, NVector(val_from), NVector(val_to), easing)
def _lerp_get(self, offset):
return self._kf.interpolated_value(offset / self.n_frames)[0]
class SineDisplacer(PointDisplacer):
def __init__(
self,
wavelength,
amplitude,
time_start,
time_end,
n_frames,
speed=1,
axis=90,
):
"""!
Displaces points as if they were following a sine wave
@param wavelength Distance between consecutive peaks
@param amplitude Distance from a peak to the original position
@param time_start When the animation shall start
@param time_end When the animation shall end
@param n_frames Number of keyframes to add
@param speed Number of peaks a point will go through in the given time
If negative, it will go the other way
@param axis Wave peak direction
"""
super().__init__(time_start, time_end, n_frames)
self.wavelength = wavelength
self.amplitude = amplitude
self.speed_f = math.pi * 2 * speed
self.axis = axis / 180 * math.pi
def _on_displace(self, startpos, f):
off = -math.sin(startpos[0]/self.wavelength*math.pi*2-f*self.speed_f/self.n_frames) * self.amplitude
return NVector(off * math.cos(self.axis), off * math.sin(self.axis))
class MultiSineDisplacer(PointDisplacer):
def __init__(
self,
waves,
time_start,
time_end,
n_frames,
speed=1,
axis=90,
amplitude_scale=1,
):
"""!
Displaces points as if they were following a sine wave
@param waves List of tuples (wavelength, amplitude)
@param time_start When the animation shall start
@param time_end When the animation shall end
@param n_frames Number of keyframes to add
@param speed Number of peaks a point will go through in the given time
If negative, it will go the other way
@param axis Wave peak direction
@param amplitude_scale Multiplies the resulting amplitude by this factor
"""
super().__init__(time_start, time_end, n_frames)
self.waves = waves
self.speed_f = math.pi * 2 * speed
self.axis = axis / 180 * math.pi
self.amplitude_scale = amplitude_scale
def _on_displace(self, startpos, f):
off = 0
for wavelength, amplitude in self.waves:
off -= math.sin(startpos[0]/wavelength*math.pi*2-f*self.speed_f/self.n_frames) * amplitude
off *= self.amplitude_scale
return NVector(off * math.cos(self.axis), off * math.sin(self.axis))
class DepthRotationAxis:
def __init__(self, x, y, keep):
self.x = x / x.length
self.y = y / y.length
self.keep = keep / keep.length # should be the cross product
def rot_center(self, center, point):
return (
self.x * self.x.dot(center) +
self.y * self.y.dot(center) +
self.keep * self.keep.dot(point)
)
def extract_component(self, vector, axis):
return sum(vector.element_scaled(axis).components)
@classmethod
def from_points(cls, keep_point, center=NVector(0, 0, 0)):
keep = keep_point - center
keep /= keep.length
# Hughes-Moller to find x and y
if abs(keep.x) > abs(keep.z):
y = NVector(-keep.y, keep.x, 0)
else:
y = NVector(0, -keep.z, keep.y)
y /= y.length
x = y.cross(keep)
return cls(x, y, keep)
class DepthRotation:
axis_x = DepthRotationAxis(NVector(0, 0, 1), NVector(0, 1, 0), NVector(1, 0, 0))
axis_y = DepthRotationAxis(NVector(1, 0, 0), NVector(0, 0, 1), NVector(0, 1, 0))
axis_z = DepthRotationAxis(NVector(1, 0, 0), NVector(0, 1, 0), NVector(0, 0, 1))
def __init__(self, center):
self.center = center
def rotate3d_y(self, point, angle):
return self.rotate3d(point, angle, self.axis_y)
# Hard-coded version:
#c = NVector(self.center.x, point.y, self.center.z)
#rad = angle * math.pi / 180
#delta = point - c
#pol_l = delta.length
#pol_a = math.atan2(delta.z, delta.x)
#dest_a = pol_a + rad
#return NVector(
# c.x + pol_l * math.cos(dest_a),
# point.y,
# c.z + pol_l * math.sin(dest_a)
#)
def rotate3d_x(self, point, angle):
return self.rotate3d(point, angle, self.axis_x)
# Hard-coded version:
#c = NVector(point.x, self.center.y, self.center.z)
#rad = angle * math.pi / 180
#delta = point - c
#pol_l = delta.length
#pol_a = math.atan2(delta.y, delta.z)
#dest_a = pol_a + rad
#return NVector(
# point.x,
# c.y + pol_l * math.sin(dest_a),
# c.z + pol_l * math.cos(dest_a),
#)
def rotate3d_z(self, point, angle):
return self.rotate3d(point, angle, self.axis_z)
def rotate3d(self, point, angle, axis):
c = axis.rot_center(self.center, point)
rad = angle * math.pi / 180
delta = point - c
pol_l = delta.length
pol_a = math.atan2(
axis.extract_component(delta, axis.y),
axis.extract_component(delta, axis.x)
)
dest_a = pol_a + rad
return c + axis.x * pol_l * math.cos(dest_a) + axis.y * pol_l * math.sin(dest_a)
class DepthRotationDisplacer(PointDisplacer):
axis_x = DepthRotation.axis_x
axis_y = DepthRotation.axis_y
axis_z = DepthRotation.axis_z
def __init__(self, center, time_start, time_end, n_frames, axis,
depth=0, angle=360, anglestart=0, ease=easing.Linear()):
super().__init__(time_start, time_end, n_frames)
self.rotation = DepthRotation(center)
if isinstance(axis, NVector):
axis = DepthRotationAxis.from_points(axis)
self.axis = axis
self.depth = depth
self._angle = angle
self.anglestart = anglestart
self.ease = ease
self._init_lerp(0, angle, ease)
@property
def angle(self):
return self._angle
@angle.setter
def angle(self, value):
self._angle = value
self._init_lerp(0, value, self.ease)
def _on_displace(self, startpos, f):
angle = self.anglestart + self._lerp_get(f)
if len(startpos) < 3:
startpos = NVector(*(startpos.components + [self.depth]))
return self.rotation.rotate3d(startpos, angle, self.axis) - startpos
class EnvelopeDeformation(PointDisplacer):
def __init__(self, topleft, bottomright):
self.topleft = topleft
self.size = bottomright - topleft
self.keyframes = []
@property
def time_start(self):
return self.keyframes[0][0]
def add_reset_keyframe(self, time):
self.add_keyframe(
time,
self.topleft.clone(),
NVector(self.topleft.x + self.size.x, self.topleft.y),
NVector(self.topleft.x + self.size.x, self.topleft.y + self.size.y),
NVector(self.topleft.x, self.topleft.y + self.size.y),
)
def add_keyframe(self, time, tl, tr, br, bl):
self.keyframes.append([
time,
tl.clone(),
tr.clone(),
br.clone(),
bl.clone()
])
def _on_displace(self, startpos, f):
_, tl, tr, br, bl = self.keyframes[f]
relp = startpos - self.topleft
relp.x /= self.size.x
relp.y /= self.size.y
x1 = tl.lerp(tr, relp.x)
x2 = bl.lerp(br, relp.x)
#return x1.lerp(x2, relp.y)
return x1.lerp(x2, relp.y) - startpos
@property
def n_frames(self):
return len(self.keyframes)-1
def frame_time(self, f):
return self.keyframes[f][0]
class DisplacerDampener(PointDisplacer):
"""!
Given a displacer and a function that returns a factor for a point,
multiplies the effect of the displacer by the factor
"""
def __init__(self, displacer, dampener):
self.displacer = displacer
self.dampener = dampener
@property
def time_start(self):
return self.displacer.time_start
def _on_displace(self, startpos, f):
disp = self.displacer._on_displace(startpos, f)
damp = self.dampener(startpos)
return disp * damp
@property
def n_frames(self):
return self.displacer.n_frames
def frame_time(self, f):
return self.displacer.frame_time(f)
class FollowDisplacer(PointDisplacer):
def __init__(
self,
origin,
range,
offset_func,
time_start, time_end, n_frames,
falloff_exp=1,
):
"""!
@brief Uses a custom offset function, and applies a falloff to the displacement
@param origin Origin point for the falloff
@param range Radius after which the points will not move
@param offset_func Function returning an offset given a ratio of the time
@param time_start When the animation shall start
@param time_end When the animation shall end
@param n_frames Number of frames in the animation
@param falloff_exp Exponent for the falloff
"""
super().__init__(time_start, time_end, n_frames)
self.origin = origin
self.range = range
self.offset_func = offset_func
self.falloff_exp = falloff_exp
def _on_displace(self, startpos, f):
influence = 1 - min(1, (startpos - self.origin).length / self.range) ** self.falloff_exp
return self.offset_func(f / self.n_frames) * influence
+447
View File
@@ -0,0 +1,447 @@
import enum
import math
import colorsys
from ..nvector import NVector
def from_uint8(r, g, b, a=255):
return Color(r, g, b, a) / 255
class ColorMode(enum.Enum):
## sRGB, Components in [0, 1]
RGB = enum.auto()
## HSV, components in [0, 1]
HSV = enum.auto()
## HSL, components in [0, 1]
HSL = enum.auto()
## CIE XYZ with Illuminant D65. Components in [0, 1]
XYZ = enum.auto()
## CIE L*u*v*
LUV = enum.auto()
## CIE Lch(uv), polar version of LUV where C is the radius and H an angle in radians
LCH_uv = enum.auto()
## CIE L*a*b*
LAB = enum.auto()
## CIE LCh(ab), polar version of LAB where C is the radius and H an angle in radians
#LCH_ab = enum.auto()
def _clamp(x):
return max(0, min(1, x))
class Conversion:
_conv_paths = {
(ColorMode.RGB, ColorMode.RGB): [],
(ColorMode.RGB, ColorMode.HSV): [],
(ColorMode.RGB, ColorMode.HSL): [],
(ColorMode.RGB, ColorMode.XYZ): [],
(ColorMode.RGB, ColorMode.LUV): [ColorMode.XYZ],
(ColorMode.RGB, ColorMode.LAB): [ColorMode.XYZ],
(ColorMode.RGB, ColorMode.LCH_uv): [ColorMode.XYZ, ColorMode.LUV],
#(ColorMode.RGB, ColorMode.LCH_ab): [ColorMode.XYZ, ColorMode.LAB],
(ColorMode.HSV, ColorMode.RGB): [],
(ColorMode.HSV, ColorMode.HSV): [],
(ColorMode.HSV, ColorMode.HSL): [],
(ColorMode.HSV, ColorMode.XYZ): [ColorMode.RGB],
(ColorMode.HSV, ColorMode.LUV): [ColorMode.RGB, ColorMode.XYZ],
(ColorMode.HSV, ColorMode.LAB): [ColorMode.RGB, ColorMode.XYZ],
(ColorMode.HSV, ColorMode.LCH_uv): [ColorMode.RGB, ColorMode.XYZ, ColorMode.LUV],
#(ColorMode.HSV, ColorMode.LCH_ab): [ColorMode.RGB, ColorMode.XYZ, ColorMode.LAB],
(ColorMode.HSL, ColorMode.RGB): [],
(ColorMode.HSL, ColorMode.HSV): [],
(ColorMode.HSL, ColorMode.HSL): [],
(ColorMode.HSL, ColorMode.XYZ): [ColorMode.RGB],
(ColorMode.HSL, ColorMode.LUV): [ColorMode.RGB, ColorMode.XYZ],
(ColorMode.HSL, ColorMode.LAB): [ColorMode.RGB, ColorMode.XYZ],
(ColorMode.HSL, ColorMode.LCH_uv): [ColorMode.RGB, ColorMode.XYZ, ColorMode.LUV],
#(ColorMode.HSL, ColorMode.LCH_ab): [ColorMode.RGB, ColorMode.XYZ, ColorMode.LAB],
(ColorMode.XYZ, ColorMode.RGB): [],
(ColorMode.XYZ, ColorMode.HSV): [ColorMode.RGB],
(ColorMode.XYZ, ColorMode.HSL): [ColorMode.RGB],
(ColorMode.XYZ, ColorMode.XYZ): [],
(ColorMode.XYZ, ColorMode.LUV): [],
(ColorMode.XYZ, ColorMode.LAB): [],
(ColorMode.XYZ, ColorMode.LCH_uv): [ColorMode.LUV],
#(ColorMode.XYZ, ColorMode.LCH_ab): [ColorMode.LAB],
(ColorMode.LCH_uv, ColorMode.RGB): [ColorMode.LUV, ColorMode.XYZ],
(ColorMode.LCH_uv, ColorMode.HSV): [ColorMode.LUV, ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LCH_uv, ColorMode.HSL): [ColorMode.LUV, ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LCH_uv, ColorMode.XYZ): [ColorMode.LUV],
(ColorMode.LCH_uv, ColorMode.LUV): [],
(ColorMode.LCH_uv, ColorMode.LAB): [ColorMode.LUV, ColorMode.XYZ],
(ColorMode.LCH_uv, ColorMode.LCH_uv): [],
#(ColorMode.LCH_uv, ColorMode.LCH_ab): [ColorMode.LUV, ColorMode.XYZ, ColorMode.LAB],
(ColorMode.LUV, ColorMode.RGB): [ColorMode.XYZ],
(ColorMode.LUV, ColorMode.HSV): [ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LUV, ColorMode.HSL): [ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LUV, ColorMode.XYZ): [],
(ColorMode.LUV, ColorMode.LUV): [],
(ColorMode.LUV, ColorMode.LAB): [ColorMode.XYZ],
(ColorMode.LUV, ColorMode.LCH_uv): [],
#(ColorMode.LUV, ColorMode.LCH_ab): [ColorMode.XYZ, ColorMode.LAB],
(ColorMode.LAB, ColorMode.RGB): [ColorMode.XYZ],
(ColorMode.LAB, ColorMode.HSV): [ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LAB, ColorMode.HSL): [ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LAB, ColorMode.XYZ): [],
(ColorMode.LAB, ColorMode.LUV): [ColorMode.XYZ],
(ColorMode.LAB, ColorMode.LAB): [],
(ColorMode.LAB, ColorMode.LCH_uv): [ColorMode.XYZ, ColorMode.LUV],
#(ColorMode.LAB, ColorMode.LCH_ab): [],
#(ColorMode.LCH_ab, ColorMode.RGB): [ColorMode.LAB, ColorMode.XYZ],
#(ColorMode.LCH_ab, ColorMode.HSV): [ColorMode.LAB, ColorMode.XYZ, ColorMode.RGB],
#(ColorMode.LCH_ab, ColorMode.HSL): [ColorMode.LAB, ColorMode.XYZ, ColorMode.RGB],
#(ColorMode.LCH_ab, ColorMode.XYZ): [ColorMode.LAB],
#(ColorMode.LCH_ab, ColorMode.LUV): [ColorMode.LAB, ColorMode.XYZ],
#(ColorMode.LCH_ab, ColorMode.LAB): [],
#(ColorMode.LCH_ab, ColorMode.LCH_uv): [ColorMode.LAB, ColorMode.XYZ, ColorMode.LUV],
#(ColorMode.LCH_ab, ColorMode.LCH_ab): [],
}
@staticmethod
def rgb_to_hsv(r, g, b):
return colorsys.rgb_to_hsv(r, g, b)
@staticmethod
def hsv_to_rgb(r, g, b):
return colorsys.hsv_to_rgb(r, g, b)
@staticmethod
def hsl_to_hsv(h, s_hsl, l):
v = l + s_hsl * min(l, 1 - l)
s_hsv = 0 if v == 0 else 2 - 2 * l / v
return (h, s_hsv, v)
@staticmethod
def hsv_to_hsl(h, s_hsv, v):
l = v - v * s_hsv / 2
s_hsl = 0 if l in (0, 1) else (v - l) / min(l, 1 - l)
return (h, s_hsl, l)
@staticmethod
def rgb_to_hsl(r, g, b):
h, l, s = colorsys.rgb_to_hls(r, g, b)
return (h, s, l)
@staticmethod
def hsl_to_rgb(h, s, l):
return colorsys.hls_to_rgb(h, l, s)
# http://w3.uqo.ca/missaoui/Publications/TRColorSpace.zip
#@staticmethod
#def rgb_to_hcl(r, g, b, gamma=3, y0=100):
#maxc = max(r, g, b)
#minc = min(r, g, b)
#if maxc > 0:
#alpha = 1/y0 * minc / maxc
#else:
#alpha = 0
#q = math.e ** (alpha * gamma)
#h = math.atan2(g - b, r - g)
#if h < 0:
#h += 2*math.pi
#h /= 2*math.pi
#c = q / 3 * (abs(r-g) + abs(g-b) + abs(b-r))
#l = (q * maxc + (q-1) * minc) / 2
#return (h, c, l)
#@staticmethod
#def hcl_to_rgb(h, c, l, gamma=3, y0=100):
#h *= 2*math.pi
#q = math.e ** ((1 - 2*c / 4*l) * gamma / y0)
#minc = (4*l - 3*c) / (4*q - 2)
#maxc = minc + 3*c / 2*q
#if h <= math.pi * 1 / 3:
#tan = math.tan(3/2*h)
#r = maxc
#b = minc
#g = (r * tan + b) / (1 + tan)
#elif h <= math.pi * 2 / 3:
#tan = math.tan(3/4*(h-math.pi))
#g = maxc
#b = minc
#r = (g * (1+tan) - b) / tan
#elif h <= math.pi * 3 / 3:
#tan = math.tan(3/4*(h-math.pi))
#g = maxc
#r = minc
#b = g * (1+tan) - r * tan
#elif h <= math.pi * 4 / 3:
#tan = math.tan(3/2*(h+math.pi))
#b = maxc
#r = minc
#g = (r * tan + b) / (1 + tan)
#elif h <= math.pi * 5 / 3:
#tan = math.tan(3/4*h)
#b = maxc
#g = minc
#r = (g * (1+tan) - b) / tan
#else:
#tan = math.tan(3/4*h)
#r = maxc
#g = minc
#b = g * (1+tan) - r * tan
#return _clamp(r), _clamp(g), _clamp(b)
@staticmethod
def rgb_to_xyz(r, g, b):
def _gamma(v):
return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4
rgb = (_gamma(r), _gamma(g), _gamma(b))
matrix = [
[0.4124564, 0.3575761, 0.1804375],
[0.2126729, 0.7151522, 0.0721750],
[0.0193339, 0.1191920, 0.9503041],
]
return tuple(
sum(rgb[i] * c for i, c in enumerate(row))
for row in matrix
)
@staticmethod
def xyz_to_rgb(x, y, z):
def _gamma1(v):
return _clamp(v * 12.92 if v <= 0.0031308 else v ** (1/2.4) * 1.055 - 0.055)
matrix = [
[+3.2404542, -1.5371385, -0.4985314],
[-0.9692660, +1.8760108, +0.0415560],
[+0.0556434, -0.2040259, +1.0572252],
]
xyz = (x, y, z)
return tuple(map(_gamma1, (
sum(xyz[i] * c for i, c in enumerate(row))
for row in matrix
)))
@staticmethod
def xyz_to_luv(x, y, z):
u1r = 0.2009
v1r = 0.4610
yr = 100
kap = (29/3)**3
eps = (6/29)**3
try:
u1 = 4*x / (x + 15*y + 3*z)
v1 = 9*y / (x + 15*y + 3*z)
except ZeroDivisionError:
return 0, 0, 0
y_r = y/yr
l = 166 * y_r ** (1/3) - 16 if y_r > eps else kap * y_r
u = 13 * l * (u1 - u1r)
v = 13 * l * (v1 - v1r)
return l, u, v
@staticmethod
def luv_to_xyz(l, u, v):
u1r = 0.2009
v1r = 0.4610
yr = 100
kap = (29/3)**3
if l == 0:
u1 = u1r
v1 = v1r
else:
u1 = u / (13 * l) + u1r
v1 = v / (13 * l) + v1r
y = yr * l / kap if l <= 8 else yr * ((l + 16) / 116) ** 3
x = y * 9*u1 / (4*v1)
z = y * (12 - 3*u1 - 20*v1) / (4*v1)
return x, y, z
@staticmethod
def luv_to_lch_uv(l, u, v):
c = math.hypot(u, v)
h = math.atan2(v, u)
if h < 0:
h += math.tau
return l, c, h
@staticmethod
def lch_uv_to_luv(l, c, h):
u = math.cos(h) * c
v = math.sin(h) * c
return l, u, v
@staticmethod
def xyz_to_lab(x, y, z):
# D65 Illuminant aka sRGB(1,1,1)
xn = 0.950489
yn = 1
zn = 108.8840
delta = 6 / 29
def f(t):
return t ** (1/3) if t > delta ** 3 else t / (3*delta**2) + 4/29
fy = f(y/yn)
l = 116 * fy - 16
a = 500 * (f(x/xn) - fy)
b = 200 * (fy - f(z/zn))
return l, a, b
@staticmethod
def lab_to_xyz(l, a, b):
# D65 Illuminant aka sRGB(1,1,1)
xn = 0.950489
yn = 1
zn = 108.8840
delta = 6 / 29
def f1(t):
return t**3 if t > delta else 3*delta**2*(t-4/29)
l1 = (l+16) / 116
x = xn * f1(l1+a/500)
y = yn * f1(l1)
z = zn * f1(l1-b/200)
return x, y, z
#@staticmethod
#def lab_to_lch_ab(l, a, b):
#c = math.hypot(a, b)
#h = math.atan2(b, a)
#if h < 0:
#h += math.tau
#return l, c, h
#@staticmethod
#def lch_ab_to_lab(l, c, h):
#a = math.cos(h) * c
#b = math.sin(h) * c
#return l, a, b
@staticmethod
def conv_func(mode_from, mode_to):
return getattr(Conversion, "%s_to_%s" % (mode_from.name.lower(), mode_to.name.lower()), None)
@staticmethod
def convert(tuple, mode_from, mode_to):
if mode_from == mode_to:
return tuple
if len(tuple) == 4:
alpha = tuple[3]
tuple = tuple[:3]
else:
alpha = None
func = Conversion.conv_func(mode_from, mode_to)
if func:
return func(*tuple)
if (mode_from, mode_to) in Conversion._conv_paths:
steps = Conversion._conv_paths[(mode_from, mode_to)] + [mode_to]
for step in steps:
func = Conversion.conv_func(mode_from, step)
if not func:
raise ValueError("Missing definition for conversion from %s to %s" % (mode_from, step))
tuple = func(*tuple)
mode_from = step
if alpha is not None:
tuple += (alpha,)
return tuple
raise ValueError("No conversion path from %s to %s" % (mode_from, mode_to))
class Color(NVector):
Mode = ColorMode
def __init__(self, c1=0, c2=0, c3=0, a=1, *, mode=ColorMode.RGB):
if isinstance(a, ColorMode):
raise TypeError("Please update the Color constructor")
super().__init__(c1, c2, c3, a)
self._mode = mode
@property
def mode(self):
return self._mode
def convert(self, v):
if v == self._mode:
return self
self.components = list(Conversion.convert(self.components, self._mode, v))
self._mode = v
return self
def clone(self):
return Color(*self.components, mode=self._mode)
def converted(self, mode):
return self.clone().convert(mode)
def to_rgb(self):
return self.converted(ColorMode.RGB)
def __repr__(self):
return "<%s %s [%.3f, %.3f, %.3f, %.3f]>" % (
(self.__class__.__name__, self.mode.name) + tuple(self.components)
)
def component_names(self):
comps = None
if self._mode == ColorMode.RGB:
comps = ({"r", "red"}, {"g", "green"}, {"b", "blue"})
elif self._mode == ColorMode.HSV:
comps = ({"h", "hue"}, {"s", "saturation"}, {"v", "value"})
elif self._mode == ColorMode.HSL:
comps = ({"h", "hue"}, {"s", "saturation"}, {"l", "lightness"})
elif self._mode == ColorMode.LCH_uv: # in (ColorMode.LCH_uv, ColorMode.LCH_ab):
comps = ({"l", "luma", "luminance"}, {"c", "choma"}, {"h", "hue"})
elif self._mode == ColorMode.XYZ:
comps = "xyz"
elif self._mode == ColorMode.LUV:
comps = "luv"
elif self._mode == ColorMode.LAB:
comps = "lab"
return comps
def _attrindex(self, name):
comps = self.component_names()
if comps:
for i, vals in enumerate(comps):
if name in vals:
return i
return None
def __getattr__(self, name):
if name not in vars(self) and name not in {"_mode", "components"}:
i = self._attrindex(name)
if i is not None:
return self.components[i]
raise AttributeError(name)
def __setattr__(self, name, value):
if name not in vars(self) and name not in {"_mode", "components"}:
i = self._attrindex(name)
if i is not None:
self.components[i] = value
return
return super().__setattr__(name, value)
+124
View File
@@ -0,0 +1,124 @@
import math
from ..nvector import NVector
from ..objects.bezier import BezierPoint
## @todo Just output a Bezier object
class Ellipse:
def __init__(self, center, radii, xrot):
"""
@param center 2D vector, center of the ellipse
@param radii 2D vector, x/y radius of the ellipse
@param xrot Angle between the main axis of the ellipse and the x axis (in radians)
"""
self.center = center
self.radii = radii
self.xrot = xrot
def point(self, t):
return NVector(
self.center[0]
+ self.radii[0] * math.cos(self.xrot) * math.cos(t)
- self.radii[1] * math.sin(self.xrot) * math.sin(t),
self.center[1]
+ self.radii[0] * math.sin(self.xrot) * math.cos(t)
+ self.radii[1] * math.cos(self.xrot) * math.sin(t)
)
def derivative(self, t):
return NVector(
- self.radii[0] * math.cos(self.xrot) * math.sin(t)
- self.radii[1] * math.sin(self.xrot) * math.cos(t),
- self.radii[0] * math.sin(self.xrot) * math.sin(t)
+ self.radii[1] * math.cos(self.xrot) * math.cos(t)
)
def to_bezier(self, anglestart, angle_delta):
points = []
angle1 = anglestart
angle_left = abs(angle_delta)
step = math.pi / 2
sign = -1 if anglestart+angle_delta < angle1 else 1
# We need to fix the first handle
firststep = min(angle_left, step) * sign
alpha = self._alpha(firststep)
q1 = self.derivative(angle1) * alpha
points.append(BezierPoint(self.point(angle1), NVector(0, 0), q1))
# Then we iterate until the angle has been completed
tolerance = step / 2
while angle_left > tolerance:
lstep = min(angle_left, step)
step_sign = lstep * sign
angle2 = angle1 + step_sign
angle_left -= abs(lstep)
alpha = self._alpha(step_sign)
p2 = self.point(angle2)
q2 = self.derivative(angle2) * alpha
points.append(BezierPoint(p2, -q2, q2))
angle1 = angle2
return points
def _alpha(self, step):
return math.sin(step) * (math.sqrt(4+3*math.tan(step/2)**2) - 1) / 3
@classmethod
def from_svg_arc(cls, start, rx, ry, xrot, large, sweep, dest):
rx = abs(rx)
ry = abs(ry)
x1 = start[0]
y1 = start[1]
x2 = dest[0]
y2 = dest[1]
phi = math.pi * xrot / 180
x1p, y1p = _matrix_mul(phi, (start-dest)/2, -1)
cr = x1p ** 2 / rx**2 + y1p**2 / ry**2
if cr > 1:
s = math.sqrt(cr)
rx *= s
ry *= s
dq = rx**2 * y1p**2 + ry**2 * x1p**2
pq = (rx**2 * ry**2 - dq) / dq
cpm = math.sqrt(max(0, pq))
if large == sweep:
cpm = -cpm
cp = NVector(cpm * rx * y1p / ry, -cpm * ry * x1p / rx)
c = _matrix_mul(phi, cp) + NVector((x1+x2)/2, (y1+y2)/2)
theta1 = _angle(NVector(1, 0), NVector((x1p - cp[0]) / rx, (y1p - cp[1]) / ry))
deltatheta = _angle(
NVector((x1p - cp[0]) / rx, (y1p - cp[1]) / ry),
NVector((-x1p - cp[0]) / rx, (-y1p - cp[1]) / ry)
) % (2*math.pi)
if not sweep and deltatheta > 0:
deltatheta -= 2*math.pi
elif sweep and deltatheta < 0:
deltatheta += 2*math.pi
return cls(c, NVector(rx, ry), phi), theta1, deltatheta
def _matrix_mul(phi, p, sin_mul=1):
c = math.cos(phi)
s = math.sin(phi) * sin_mul
xr = c * p.x - s * p.y
yr = s * p.x + c * p.y
return NVector(xr, yr)
def _angle(u, v):
arg = math.acos(max(-1, min(1, u.dot(v) / (u.length * v.length))))
if u[0] * v[1] - u[1] * v[0] < 0:
return -arg
return arg
+13
View File
@@ -0,0 +1,13 @@
from contextlib import contextmanager
@contextmanager
def open_file(file_or_name, mode="w"):
if isinstance(file_or_name, str):
obj = open(file_or_name, mode)
try:
yield obj
finally:
obj.close()
else:
yield file_or_name
+831
View File
@@ -0,0 +1,831 @@
import os
import sys
import subprocess
import fontTools.pens.basePen
import fontTools.ttLib
import fontTools.t1Lib
from fontTools.pens.boundsPen import ControlBoundsPen
import enum
import math
from xml.etree import ElementTree
from ..nvector import NVector
from ..objects.bezier import Bezier, BezierPoint
from ..objects.shapes import Path, Group, Fill, Stroke
from ..objects.text import TextJustify
from ..objects.base import LottieProp, CustomObject
from ..objects.layers import ShapeLayer
class BezierPen(fontTools.pens.basePen.BasePen):
def __init__(self, glyphSet, offset=NVector(0, 0)):
super().__init__(glyphSet)
self.beziers = []
self.current = Bezier()
self.offset = offset
def _point(self, pt):
return self.offset + NVector(pt[0], -pt[1])
def _moveTo(self, pt):
self._endPath()
def _endPath(self):
if len(self.current.points):
self.beziers.append(self.current)
self.current = Bezier()
def _closePath(self):
self.current.close()
self._endPath()
def _lineTo(self, pt):
if len(self.current.points) == 0:
self.current.points.append(self._point(self._getCurrentPoint()))
self.current.points.append(self._point(pt))
def _curveToOne(self, pt1, pt2, pt3):
if len(self.current.points) == 0:
cp = self._point(self._getCurrentPoint())
self.current.points.append(
BezierPoint(
cp,
None,
self._point(pt1) - cp
)
)
else:
self.current.points[-1].out_tangent = self._point(pt1) - self.current.points[-1].vertex
dest = self._point(pt3)
self.current.points.append(
BezierPoint(
dest,
self._point(pt2) - dest,
None,
)
)
class SystemFont:
def __init__(self, family):
self.family = family
self.files = {}
self.styles = set()
self._renderers = {}
def add_file(self, styles, file):
self.styles |= set(styles)
key = self._key(styles)
self.files.setdefault(key, file)
def filename(self, styles):
return self.files[self._key(styles)]
def _key(self, styles):
if isinstance(styles, str):
return (styles,)
return tuple(sorted(styles))
def __getitem__(self, styles):
key = self._key(styles)
if key in self._renderers:
return self._renderers[key]
fr = RawFontRenderer(self.files[key])
self._renderers[key] = fr
return fr
def __repr__(self):
return "<SystemFont %s>" % self.family
class FontQuery:
"""!
@see https://www.freedesktop.org/software/fontconfig/fontconfig-user.html#AEN21
https://manpages.ubuntu.com/manpages/cosmic/man1/fc-pattern.1.html
"""
def __init__(self, str=""):
self._query = {}
if isinstance(str, FontQuery):
self._query = str._query.copy()
elif str:
chunks = str.split(":")
family = chunks.pop(0)
self._query = dict(
chunk.split("=")
for chunk in chunks
if chunk
)
self.family(family)
def family(self, name):
self._query["family"] = name
return self
def weight(self, weight):
self._query["weight"] = weight
return self
def css_weight(self, weight):
"""!
Weight from CSS weight value.
Weight is different between CSS and fontconfig
This creates some interpolations to ensure known values are translated properly
@see https://www.freedesktop.org/software/fontconfig/fontconfig-user.html#AEN178
https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#Common_weight_name_mapping
"""
if weight < 200:
v = max(0, weight - 100) / 100 * 40
elif weight < 500:
v = -weight**3 / 200000 + weight**2 * 11/2000 - weight * 17/10 + 200
elif weight < 700:
v = -weight**2 * 3/1000 + weight * 41/10 - 1200
else:
v = (weight - 700) / 200 * 10 + 200
return self.weight(int(round(v)))
def style(self, *styles):
self._query["style"] = " ".join(styles)
return self
def charset(self, *hex_ranges):
self._query["charset"] = " ".join(hex_ranges)
return self
def char(self, char):
return self.charset("%x" % ord(char))
def custom(self, property, value):
self._query[property] = value
return self
def clone(self):
return FontQuery(self)
def __getitem__(self, key):
return self._query.get(key, "")
def __contains__(self, item):
return item in self._query
def get(self, key, default=None):
return self._query.get(key, default)
def __str__(self):
return self._query.get("family", "") + ":" + ":".join(
"%s=%s" % (p, v)
for p, v in self._query.items()
if p != "family"
)
def __repr__(self):
return "<FontQuery %r>" % str(self)
def weight_to_css(self):
x = int(self["weight"])
if x < 40:
v = x / 40 * 100 + 100
elif x < 100:
v = x**3/300 - x**2 * 11/15 + x*167/3 - 3200/3
elif x < 200:
v = (2050 - 10 * math.sqrt(5) * math.sqrt(1205 - 6 * x)) / 3
else:
v = (x - 200) * 200 / 10 + 700
return int(round(v))
class _SystemFontList:
def __init__(self):
self.fonts = None
def _lazy_load(self):
if self.fonts is None:
self.load()
def load(self):
self.fonts = {}
self.load_fc_list()
def cmd(self, *a):
p = subprocess.Popen(a, stdout=subprocess.PIPE)
out, err = p.communicate()
out = out.decode("utf-8").strip()
return out, p.returncode
def load_fc_list(self):
out, returncode = self.cmd("fc-list", r'--format=%{file}\t%{family[0]}\t%{style[0]}\n')
if returncode == 0:
for line in out.splitlines():
file, family, styles = line.split("\t")
self._get(family).add_file(styles.split(" "), file)
def best(self, query):
"""!
Returns the renderer best matching the name
"""
out, returncode = self.cmd("fc-match", r"--format=%{family}\t%{style}", str(query))
if returncode == 0:
return self._font_from_match(out)
def _font_from_match(self, out):
fam, style = out.split("\t")
fam = fam.split(",")[0]
style = style.split(",")[0].split()
return self[fam][style]
def all(self, query):
"""!
Yields all the renderers matching a query
"""
out, returncode = self.cmd("fc-match", "-s", r"--format=%{family}\t%{style}\n", str(query))
if returncode == 0:
for line in out.splitlines():
try:
yield self._font_from_match(line)
except (fontTools.ttLib.TTLibError, fontTools.t1Lib.T1Error):
pass
def default(self):
"""!
Returns the default fornt renderer
"""
return self.best()
def _get(self, family):
self._lazy_load()
if family in self.fonts:
return self.fonts[family]
font = SystemFont(family)
self.fonts[family] = font
return font
def __getitem__(self, key):
self._lazy_load()
return self.fonts[key]
def __iter__(self):
self._lazy_load()
return iter(self.fonts.values())
def keys(self):
self._lazy_load()
return self.fonts.keys()
def __contains__(self, item):
self._lazy_load()
return item in self.fonts
## Dictionary of system fonts
fonts = _SystemFontList()
def collect_kerning_pairs(font):
if "GPOS" not in font:
return {}
gpos_table = font["GPOS"].table
unique_kern_lookups = set()
for item in gpos_table.FeatureList.FeatureRecord:
if item.FeatureTag == "kern":
feature = item.Feature
unique_kern_lookups |= set(feature.LookupListIndex)
kerning_pairs = {}
for kern_lookup_index in sorted(unique_kern_lookups):
lookup = gpos_table.LookupList.Lookup[kern_lookup_index]
if lookup.LookupType in {2, 9}:
for pairPos in lookup.SubTable:
if pairPos.LookupType == 9: # extension table
if pairPos.ExtensionLookupType == 8: # contextual
continue
elif pairPos.ExtensionLookupType == 2:
pairPos = pairPos.ExtSubTable
if pairPos.Format != 1:
continue
firstGlyphsList = pairPos.Coverage.glyphs
for ps_index, _ in enumerate(pairPos.PairSet):
for pairValueRecordItem in pairPos.PairSet[ps_index].PairValueRecord:
secondGlyph = pairValueRecordItem.SecondGlyph
valueFormat = pairPos.ValueFormat1
if valueFormat == 5: # RTL kerning
kernValue = "<%d 0 %d 0>" % (
pairValueRecordItem.Value1.XPlacement,
pairValueRecordItem.Value1.XAdvance)
elif valueFormat == 0: # RTL pair with value <0 0 0 0>
kernValue = "<0 0 0 0>"
elif valueFormat == 4: # LTR kerning
kernValue = pairValueRecordItem.Value1.XAdvance
else:
print(
"\tValueFormat1 = %d" % valueFormat,
file=sys.stdout)
continue # skip the rest
kerning_pairs[(firstGlyphsList[ps_index], secondGlyph)] = kernValue
return kerning_pairs
class GlyphMetrics:
def __init__(self, glyph, lsb, aw, xmin, xmax):
self.glyph = glyph
self.lsb = lsb
self.advance = aw
self.xmin = xmin
self.xmax = xmax
self.width = xmax - xmin
self.advance = xmax
def draw(self, pen):
return self.glyph.draw(pen)
class Font:
def __init__(self, wrapped):
self.wrapped = wrapped
if isinstance(self.wrapped, fontTools.ttLib.TTFont):
self.cmap = self.wrapped.getBestCmap() or {}
else:
self.cmap = {}
self.glyphset = self.wrapped.getGlyphSet()
@classmethod
def open(cls, filename):
try:
f = fontTools.ttLib.TTFont(filename)
except fontTools.ttLib.TTLibError:
f = fontTools.t1Lib.T1Font(filename)
f.parse()
return cls(f)
def getGlyphSet(self):
return self.wrapped.getGlyphSet()
def getBestCmap(self):
return {}
def glyph_name(self, codepoint):
if isinstance(codepoint, str):
if len(codepoint) != 1:
return ""
codepoint = ord(codepoint)
if codepoint in self.cmap:
return self.cmap[codepoint]
return self.calculated_glyph_name(codepoint)
@staticmethod
def calculated_glyph_name(codepoint):
from fontTools import agl # Adobe Glyph List
if codepoint in agl.UV2AGL:
return agl.UV2AGL[codepoint]
elif codepoint <= 0xFFFF:
return "uni%04X" % codepoint
else:
return "u%X" % codepoint
def scale(self):
if isinstance(self.wrapped, fontTools.ttLib.TTFont):
return 1 / self.wrapped["head"].unitsPerEm
elif isinstance(self.wrapped, fontTools.t1Lib.T1Font):
return self.wrapped["FontMatrix"][0]
def yMax(self):
if isinstance(self.wrapped, fontTools.ttLib.TTFont):
return self.wrapped["head"].yMax
elif isinstance(self.wrapped, fontTools.t1Lib.T1Font):
return self.wrapped["FontBBox"][3]
def glyph(self, glyph_name):
if isinstance(self.wrapped, fontTools.ttLib.TTFont):
glyph = self.glyphset[glyph_name]
xmin = getattr(glyph._glyph, "xMin", glyph.lsb)
xmax = getattr(glyph._glyph, "xMax", glyph.width)
return GlyphMetrics(glyph, glyph.lsb, glyph.width, xmin, xmax)
elif isinstance(self.wrapped, fontTools.t1Lib.T1Font):
glyph = self.glyphset[glyph_name]
bounds_pen = ControlBoundsPen(self.glyphset)
bounds = bounds_pen.bounds
glyph.draw(bounds_pen)
if not hasattr(glyph, "width"):
advance = bounds[2]
else:
advance = glyph.width
return GlyphMetrics(glyph, bounds[0], advance, bounds[0], bounds[2])
def __contains__(self, key):
if isinstance(self.wrapped, fontTools.t1Lib.T1Font):
return key in self.wrapped.font
return key in self.wrapped
def __getitem__(self, key):
return self.wrapped[key]
class FontRenderer:
tab_width = 4
@property
def font(self):
raise NotImplementedError
def get_query(self):
raise NotImplementedError
def kerning(self, c1, c2):
return 0
def text_to_chars(self, text):
return text
def _on_missing(self, char, size, pos, group):
"""!
- Character as string
- Font size
- [in, out] Character position
- Group shape
"""
def glyph_name(self, ch):
return self.font.glyph_name(ch)
def scale(self, size):
return size * self.font.scale()
def line_height(self, size):
return self.font.yMax() * self.scale(size)
def ex(self, size):
return self.font.glyph("x").advance * self.scale(size)
def glyph_beziers(self, glyph, offset=NVector(0, 0)):
pen = BezierPen(self.font.glyphset, offset)
glyph.draw(pen)
return pen.beziers
def glyph_shapes(self, glyph, offset=NVector(0, 0)):
beziers = self.glyph_beziers(glyph, offset)
return [
Path(bez)
for bez in beziers
]
def _on_character(self, ch, size, pos, scale, line, use_kerning, chars, i):
chname = self.glyph_name(ch)
if chname in self.font.glyphset:
glyphdata = self.font.glyph(chname)
#pos.x += glyphdata.lsb * scale
glyph_shapes = self.glyph_shapes(glyphdata, pos / scale)
if glyph_shapes:
if len(glyph_shapes) > 1:
glyph_shape_group = line.add_shape(Group())
glyph_shape = glyph_shape_group
else:
glyph_shape_group = line
glyph_shape = glyph_shapes[0]
for sh in glyph_shapes:
sh.shape.value.scale(scale)
glyph_shape_group.add_shape(sh)
glyph_shape.name = ch
kerning = 0
if use_kerning and i < len(chars) - 1:
nextcname = chars[i+1]
kerning = self.kerning(chname, nextcname)
pos.x += (glyphdata.advance + kerning) * scale
return True
return False
def render(self, text, size, pos=None, use_kerning=True):
"""!
Renders some text
@param text String to render
@param size Font size (in pizels)
@param[in,out] pos Text position
@param use_kerning Whether to honour kerning info from the font file
@returns a Group shape, augmented with some extra attributes:
- line_height Line height
- next_x X position of the next character
"""
scale = self.scale(size)
line_height = self.line_height(size)
group = Group()
group.name = text
if pos is None:
pos = NVector(0, 0)
start_x = pos.x
line = Group()
group.add_shape(line)
#group.transform.scale.value = NVector(100, 100) * scale
chars = self.text_to_chars(text)
for i, ch in enumerate(chars):
if ch == "\n":
line.next_x = pos.x
pos.x = start_x
pos.y += line_height
line = Group()
group.add_shape(line)
continue
elif ch == "\t":
chname = self.glyph_name(ch)
if chname in self.font.glyphset:
width = self.font.glyph(chname).advance
else:
width = self.ex(size)
pos.x += width * scale * self.tab_width
continue
self._on_character(ch, size, pos, scale, line, use_kerning, chars, i)
group.line_height = line_height
group.next_x = line.next_x = pos.x
return group
class RawFontRenderer(FontRenderer):
def __init__(self, filename):
self.filename = filename
self._font = Font.open(filename)
self._kerning = None
@property
def font(self):
return self._font
def kerning(self, c1, c2):
if self._kerning is None:
self._kerning = collect_kerning_pairs(self.font)
return self._kerning.get((c1, c2), 0)
def __repr__(self):
return "<FontRenderer %r>" % self.filename
def get_query(self):
return self.filename
class FallbackFontRenderer(FontRenderer):
def __init__(self, query, max_attempts=10):
self.query = FontQuery(query)
self._best = None
self._bq = None
self._fallback = {}
self.max_attempts = max_attempts
@property
def font(self):
return self.best.font
def get_query(self):
return self.query
def ex(self, size):
best = self.best
if "x" not in self.font.glyphset:
best = fonts.best(self.query.clone().char("x"))
return best.ex(size)
@property
def best(self):
cq = str(self.query)
if self._best is None or self._bq != cq:
self._best = fonts.best(self.query)
self._bq = cq
return self._best
def fallback_renderer(self, char):
if char in self._fallback:
return self._fallback[char]
if len(char) != 1:
return None
codepoint = ord(char)
name = Font.calculated_glyph_name(codepoint)
for i, font in enumerate(fonts.all(self.query.clone().char(char))):
# For some reason fontconfig sometimes returns a font that doesn't
# actually contain the glyph
if name in font.font.glyphset or codepoint in font.cmap:
self._fallback[char] = font
return font
if i > self.max_attempts:
self._fallback[char] = None
return None
def _on_character(self, char, size, pos, scale, group, use_kerning, chars, i):
if self.best._on_character(char, size, pos, scale, group, use_kerning, chars, i):
return True
font = self.fallback_renderer(char)
if not font:
return False
child = font.render(char, size, pos)
if len(child.shapes) == 2:
group.add_shape(child.shapes[0])
else:
group.add_shape(child)
def __repr__(self):
return "<FallbackFontRenderer %s>" % self.query
class EmojiRenderer(FontRenderer):
_split = None
def __init__(self, wrapped, emoji_dir):
if not os.path.isdir(emoji_dir):
raise Exception("Not a valid directory: %s" % emoji_dir)
self.wrapped = wrapped
self.emoji_dir = emoji_dir
self._svgs = {}
@property
def font(self):
return self.wrapped.font
def _get_svg(self, char):
from ..parsers.svg import parse_svg_file
if char in self._svgs:
return self._svgs[char]
basename = "-".join("%x" % ord(cp) for cp in char) + ".svg"
filename = os.path.join(self.emoji_dir, basename)
if not os.path.isfile(filename):
self._svgs[char] = None
return None
svga = parse_svg_file(filename)
svgshape = Group()
svgshape.name = basename
for layer in svga.layers:
if isinstance(layer, ShapeLayer):
for shape in layer.shapes:
svgshape.add_shape(shape)
self._svgs[char] = svgshape
svgshape._bbox = svgshape.bounding_box()
return svgshape
def _on_character(self, char, size, pos, scale, group, use_kerning, chars, i):
svgshape = self._get_svg(char)
if svgshape:
target_height = self.line_height(size)
scale = target_height / svgshape._bbox.height
shape_group = Group()
shape_group = svgshape.clone()
shape_group.transform.scale.value *= scale
offset = NVector(
-svgshape._bbox.x1 + svgshape._bbox.width * 0.075,
-svgshape._bbox.y2 + svgshape._bbox.height * 0.1
)
shape_group.transform.position.value = pos + offset * scale
group.add_shape(shape_group)
pos.x += svgshape._bbox.width * scale
return True
return self.wrapped._on_character(char, size, pos, scale, group, use_kerning, chars, i)
def get_query(self):
return self.wrapped.get_query()
@staticmethod
def _get_splitter():
if EmojiRenderer._split is None:
try:
import grapheme
EmojiRenderer._split = grapheme.graphemes
except ImportError:
sys.stderr.write("Install `grapheme` for better Emoji support\n")
EmojiRenderer._split = lambda x: x
return EmojiRenderer._split
@staticmethod
def emoji_split(string):
return EmojiRenderer._get_splitter()(string)
def text_to_chars(self, string):
return list(self.emoji_split(string))
class FontStyle:
def __init__(self, query, size, justify=TextJustify.Left, position=None, use_kerning=True, emoji_svg=None):
self.emoji_svg = emoji_svg
self._set_query(query)
self.size = size
self.justify = justify
self.position = position.clone() if position else NVector(0, 0)
self.use_kerning = use_kerning
def _set_query(self, query):
if isinstance(query, str) and os.path.isfile(query):
self._renderer = RawFontRenderer(query)
else:
self._renderer = FallbackFontRenderer(query)
if self.emoji_svg:
self._renderer = EmojiRenderer(self._renderer, self.emoji_svg)
@property
def query(self):
return self._renderer.get_query()
@query.setter
def query(self, value):
if str(value) != str(self.query):
self._set_query(value)
@property
def renderer(self):
return self._renderer
def render(self, text, pos=NVector(0, 0)):
group = self._renderer.render(text, self.size, self.position+pos, self.use_kerning)
for subg in group.shapes[:-1]:
width = subg.next_x - self.position.x - pos.x
if self.justify == TextJustify.Center:
subg.transform.position.value.x -= width / 2
elif self.justify == TextJustify.Right:
subg.transform.position.value.x -= width
return group
def clone(self):
return FontStyle(str(self._renderer.query), self.size, self.justify, NVector(*self.position), self.use_kerning)
@property
def ex(self):
return self._renderer.ex(self.size)
@property
def line_height(self):
return self._renderer.line_height(self.size)
def _propfac(a):
return property(lambda s: s._get(a), lambda s, v: s._set(a, v))
class FontShape(CustomObject):
_props = [
LottieProp("query_string", "_query", str),
LottieProp("size", "_size", float),
LottieProp("justify", "_justify", TextJustify),
LottieProp("text", "_text", str),
LottieProp("position", "_position", NVector),
]
wrapped_lottie = Group
def __init__(self, text="", query="", size=64, justify=TextJustify.Left):
CustomObject.__init__(self)
if isinstance(query, FontStyle):
self.style = query
else:
self.style = FontStyle(query, size, justify)
self.text = text
self.hidden = None
def _get(self, a):
return getattr(self.style, a)
def _set(self, a, v):
return setattr(self.style, a, v)
query = _propfac("query")
size = _propfac("size")
justify = _propfac("justify")
position = _propfac("position")
@property
def query_string(self):
return str(self.query)
@query_string.setter
def query_string(self, v):
self.query = v
def _build_wrapped(self):
g = self.style.render(self.text)
self.line_height = g.line_height
return g
def bounding_box(self, time=0):
return self.wrapped.bounding_box(time)
+97
View File
@@ -0,0 +1,97 @@
from..nvector import NVector
# FABRIK
class Chain:
def __init__(self, tail, fixed_tail=True, tolerance=0.5, max_iter=8):
self.joints = [tail.clone()]
self.fixed_tail = fixed_tail
self.lengths = []
self.total_length = 0
self.tolerance = tolerance
self.max_iter = max_iter
def add_joint(self, point):
length = (point - self.joints[-1]).length
self.lengths.append(length)
self.total_length += length
self.joints.append(point.clone())
def add_joints(self, head, n):
delta = head - self.joints[-1]
self.total_length += delta.length
segment = delta / n
seglen = segment.length
for i in range(n):
self.lengths.append(seglen)
self.joints.append(self.joints[-1] + segment)
def backward(self, target):
"""!
target -> -> start
"""
self.joints[-1] = target
for i in range(len(self.joints)-2, -1, -1):
r = self.joints[i+1] - self.joints[i]
l = self.lengths[i] / r.length
self.joints[i] = self.joints[i+1].lerp(self.joints[i], l)
def forward(self, target):
"""!
start -> -> tail
"""
self.joints[0] = target
for i in range(0, len(self.joints)-1):
r = self.joints[i+1] - self.joints[i]
l = self.lengths[i] / r.length
self.joints[i+1] = self.joints[i].lerp(self.joints[i+1], l)
def reach(self, target):
if not self.fixed_tail:
self.backward(target)
return
distance = (target - self.joints[0]).length
if distance >= self.total_length:
for i in range(len(self.joints)-1):
r = target - self.joints[i]
l = self.lengths[i] / r.length
self.joints[i+1] = self.joints[i].lerp(target, l)
return
base = self.joints[0]
distance = (target - self.joints[-1]).length
n_it = 0
while distance > self.tolerance and n_it < self.max_iter:
self.backward(target)
self.forward(base)
distance = (target - self.joints[-1]).length
n_it += 1
class Octopus:
def __init__(self, master):
self.chains = {"master": master}
self.master = master
@property
def base(self):
return self.master.joints[-1]
def add_chain(self, name):
ch = Chain(self.base)
self.chains[name] = ch
return ch
def reach(self, target_map):
centroid = NVector(0, 0)
for chain, target in target_map.items():
self.chains[chain].backward(target)
centroid += self.chains[chain].joints[0]
centroid /= len(target_map)
self.master.reach(centroid)
for chain in target_map.keys():
self.chains[chain].forward(self.base)

Some files were not shown because too many files have changed in this diff Show More