mirror of
https://github.com/OpenVGLab/OmniLottie.git
synced 2026-09-16 23:26:26 +00:00
Initial Commit
This commit is contained in:
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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')
|
||||
@@ -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 "")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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__()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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__()
|
||||
@@ -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
@@ -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))
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import svg, tgs, sif
|
||||
__all__ = ["svg", "tgs", "sif"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
from .sif.nodes import *
|
||||
from . import ast
|
||||
from .ast_impl.base import SifKeyframe, Interpolation
|
||||
@@ -0,0 +1,2 @@
|
||||
from .ast_impl.nodes import *
|
||||
from .ast_impl.base import *
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -0,0 +1,9 @@
|
||||
import enum
|
||||
|
||||
|
||||
class Smooth(enum.Enum):
|
||||
NearestNeighbour = 0
|
||||
Linear = 1
|
||||
Cosine = 2
|
||||
Spline = 3
|
||||
Cubic = 4
|
||||
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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")))
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,7 @@
|
||||
__all__ = ["animation", "ellipse", "ik", "linediff", "restructure", "script", "stripper"]
|
||||
|
||||
try:
|
||||
from . import font
|
||||
__all__ += ["font"]
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
Reference in New Issue
Block a user