Initial Commit

This commit is contained in:
OmniLottie
2026-03-01 21:36:54 +08:00
commit a386c803e1
199 changed files with 42253 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
from . import base, core, sif, svg, pretty_print
from .base import exporters
from .core import export_lottie, export_tgs, export_embedded_html
from .pretty_print import prettyprint, prettyprint_summary
from .sif import export_sif
from .svg import export_svg
__all__ = [
"base", "core", "sif", "svg", "pretty_print",
"exporters", "export_lottie", "export_tgs", "export_embedded_html",
"prettyprint", "prettyprint_summary", "export_sif", "export_svg",
]
try:
from . import cairo, gif
__all__ += ["cairo", "gif"]
except ImportError:
pass
+32
View File
@@ -0,0 +1,32 @@
from ..parsers.baseporter import Baseporter, Loader, ExtraOption, io_progress
class ExporterLoader(Loader):
def __init__(self):
super().__init__(__file__, __name__, "export")
@property
def exporters(self):
return self.items
def set_options(self, parser):
group = parser.add_argument_group("Generic output options")
group.add_argument(
"--pretty", "-p",
action="store_true",
help="Pretty print (for formats that support it)",
)
group.add_argument(
"--frame",
type=int,
default=0,
help="Frame to extract (for single-image formats)",
)
super().set_options(parser)
return group
exporters = ExporterLoader()
exporter = exporters.decorator
+27
View File
@@ -0,0 +1,27 @@
import cairosvg
import io
from .base import exporter
from .svg import export_svg
def _export_cairo(func, animation, fp, frame, dpi):
intermediate = io.StringIO()
export_svg(animation, intermediate, frame)
intermediate.seek(0)
func(file_obj=intermediate, write_to=fp, dpi=dpi)
@exporter("PNG", ["png"], [], {"frame"})
def export_png(animation, fp, frame=0, dpi=96):
_export_cairo(cairosvg.svg2png, animation, fp, frame, dpi)
@exporter("PDF", ["pdf"], [], {"frame"})
def export_pdf(animation, fp, frame=0, dpi=96):
_export_cairo(cairosvg.svg2pdf, animation, fp, frame, dpi)
@exporter("PostScript", ["ps"], [], {"frame"})
def export_ps(animation, fp, frame=0, dpi=96):
_export_cairo(cairosvg.svg2ps, animation, fp, frame, dpi)
+119
View File
@@ -0,0 +1,119 @@
import sys
import json
import gzip
import codecs
from .base import exporter
from ..utils.file import open_file
from ..parsers.baseporter import ExtraOption
from .tgs_validator import TgsValidator
@exporter("Lottie JSON", ["json"], [], {"pretty"}, "lottie")
def export_lottie(animation, file, pretty=False):
with open_file(file) as fp:
kw = {}
if pretty:
kw = dict(indent=4)
json.dump(animation.to_dict(), fp, **kw)
@exporter("Telegram Animated Sticker", ["tgs"], [
ExtraOption("no_sanitize", help="Disable Sticker fit", action="store_false", dest="sanitize"),
ExtraOption("no_validate", help="Disable feature validation", action="store_false", dest="validate"),
])
def export_tgs(animation, file, sanitize=False, validate=False):
if sanitize:
animation.tgs_sanitize()
with gzip.open(file, "wb") as gzfile:
lottie_dict = animation.to_dict()
lottie_dict["tgs"] = 1
json.dump(lottie_dict, codecs.getwriter('utf-8')(gzfile))
if validate:
validator = TgsValidator()
validator(animation)
validator.check_file_size(file)
if validator.errors:
sys.stdout.write("\n".join(map(str, validator.errors))+"\n")
class HtmlOutput:
def __init__(self, animation, file):
self.animation = animation
self.file = file
def style(self):
self.file.write("""
<style>
#bodymovin { width: %spx; height: %spx; margin: auto;
background-color: white;
background-size: 64px 64px;
background-image:
linear-gradient(to right, rgba(0, 0, 0, .3) 50%%, transparent 50%%),
linear-gradient(to bottom, rgba(0, 0, 0, .3) 50%%, transparent 50%%),
linear-gradient(to bottom, white 50%%, transparent 50%%),
linear-gradient(to right, transparent 50%%, rgba(0, 0, 0, .5) 50%%);
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.5.3/lottie.js"></script>
""" % (self.animation.width, self.animation.height))
def body_pre(self):
self.file.write("""
<div id="bodymovin"></div>
<script>
var animData = {
container: document.getElementById('bodymovin'),
renderer: 'svg',
loop: true,
autoplay: true,
""")
def body_embedded(self):
self.file.write("animationData: ")
export_lottie(self.animation, self.file, True)
def body_post(self):
self.file.write("""
};
var anim = bodymovin.loadAnimation(animData);
</script>""")
def html_begin(self):
self.file.write("""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
html, body { width: 100%; height: 100%; margin: 0; }
body { display: flex; }
</style>""")
self.style()
self.file.write("</head><body>")
def html_end(self):
self.file.write("</body></html>")
@exporter("Lottie HTML", ["html", "htm"])
def export_embedded_html(animation, file):
with open_file(file) as fp:
out = HtmlOutput(animation, fp)
out.html_begin()
out.body_pre()
out.body_embedded()
out.body_post()
out.html_end()
def export_linked_html(animation, file, path):
with open_file(file) as fp:
out = HtmlOutput(animation, fp)
out.html_begin()
out.body_pre()
file.write("path: %r" % path)
out.body_post()
out.html_end()
+89
View File
@@ -0,0 +1,89 @@
import json
import string
import zipfile
from .base import exporter
from ..parsers.baseporter import ExtraOption
from ..parsers.tgs import parse_tgs
from lottie import __version__
from ..objects import assets
@exporter("dotLottie Archive", ["lottie"], [
ExtraOption("id", help="ID of the animation", default=None),
ExtraOption("append", help="Append animation to existing archive", action="store_true"),
ExtraOption("revision", help="File revision", type=int, default=None),
ExtraOption("author", help="File author", default=None),
ExtraOption("speed", help="Playback speed", type=float, default=1),
ExtraOption("theme_color", help="Theme color", type=str, default="#ffffff"),
ExtraOption("no_loop", help="Disable Looping", action="store_false", dest="loop"),
ExtraOption("no_pack", help="Don't auto-pack images", action="store_false", dest="pack_images"),
], slug="dotlottie")
def export_dotlottie(animation, file, id=None, append=False, revision=None, author=None,
speed=1.0, theme_color="#ffffff", loop=True, pack_images=True):
files = {}
if append:
with zipfile.ZipFile(file, "r") as zf:
with zf.open("manifest.json") as manifest:
meta = json.load(manifest)
for name in zf.namelist():
if name != "manifest.json":
files[name] = zf.read(name)
else:
meta = {
"generator": "Python Lottie " + __version__,
"version": 1.0,
"revision": 1,
"author": "",
"animations": [],
"custom": {}
}
if revision is not None:
meta["revision"] = revision
if author is not None:
meta["author"] = author
if id is None:
if animation.name:
idok = string.ascii_letters + string.digits + "_-"
id = "".join(filter(lambda x: x in idok, animation.name.replace(" ", "_")))
if not id:
id = "animation_%s" % len(meta["animations"])
meta["animations"].append({
"id": id,
"speed": speed,
"themeColor": theme_color,
"loop": loop,
})
if pack_images and animation.assets:
animation = animation.clone()
image_no = 0
for asset in animation.assets:
if isinstance(asset, assets.Image):
ext, data = asset.image_data()
if not ext:
continue
pathname = "images/"
while True:
basename = "image_%s.%s" % (image_no, ext)
image_no += 1
if pathname+basename not in files:
break
files[pathname+basename] = data
asset.image_path = pathname
asset.image = basename
asset.is_embedded = False
files["manifest.json"] = json.dumps(meta)
files["animations/%s.json" % id] = json.dumps(animation.to_dict())
with zipfile.ZipFile(file, "w") as zf:
for name, data in files.items():
zf.writestr(name, data)
+133
View File
@@ -0,0 +1,133 @@
import io
from PIL import Image
from PIL import features
from .cairo import export_png
from .base import exporter, io_progress
from ..parsers.baseporter import ExtraOption
def _png_gif_prepare(image):
if image.mode not in ["RGBA", "RGBa"]:
image = image.convert("RGBA")
alpha = image.getchannel("A")
image = image.convert("RGB").convert('P', palette=Image.ADAPTIVE, colors=255)
mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0)
image.paste(255, mask=mask)
return image
def _log_frame(fmt, frame_no=None, end=None):
if frame_no is None:
io_progress().report_message("%s frame rendering completed" % (fmt))
else:
io_progress().report_progress("%s rendering frame" % fmt, frame_no, end)
@exporter("GIF", ["gif"], [
ExtraOption("skip_frames", type=int, default=1, help="Only renderer 1 out of these many frames"),
])
def export_gif(animation, fp, dpi=96, skip_frames=1):
"""
Gif export
Note that it's a bit slow.
"""
start = int(animation.in_point)
end = int(animation.out_point)
frames = []
for i in range(start, end+1, skip_frames):
_log_frame("GIF", i, end)
file = io.BytesIO()
export_png(animation, file, i, dpi)
file.seek(0)
frames.append(_png_gif_prepare(Image.open(file)))
_log_frame("GIF")
io_progress().report_message("GIF Writing to file...")
duration = int(round(1000 / animation.frame_rate * skip_frames / 10)) * 10
frames[0].save(
fp,
format='GIF',
append_images=frames[1:],
save_all=True,
duration=duration,
loop=0,
transparency=255,
disposal=2,
)
@exporter("WebP", ["webp"], [
ExtraOption("lossless", action="store_true", help="If present, use lossless compression"),
ExtraOption("quality", type=int, default=80,
help="Compression effort between 0 and 100\n" +
"for lossy 0 gives the smallest size\n" +
"for lossless 0 gives the largest file"),
ExtraOption("method", type=int, default=0, help="Quality/speed trade-off (0=fast, 6=slower-better)"),
ExtraOption("skip_frames", type=int, default=1, help="Only renderer 1 out of these many frames"),
])
def export_webp(animation, fp, dpi=96, lossless=False, quality=80, method=0, skip_frames=1):
"""
Export WebP
See https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#webp
"""
if not features.check("webp_anim"):
raise Exception("WebP animations not supported in this system")
start = int(animation.in_point)
end = int(animation.out_point)
frames = []
for i in range(start, end+1, skip_frames):
_log_frame("WebP", i, end)
file = io.BytesIO()
export_png(animation, file, i, dpi)
file.seek(0)
frames.append(Image.open(file))
_log_frame("WebP")
io_progress().report_message("WebP Writing to file...")
duration = int(round(1000 / animation.frame_rate * skip_frames))
frames[0].save(
fp,
format='WebP',
append_images=frames[1:],
save_all=True,
duration=duration,
loop=0,
background=(0, 0, 0, 0),
lossless=lossless,
quality=quality,
method=method
)
@exporter("TIFF", ["tiff"])
def export_tiff(animation, fp, dpi=96):
"""
Export TIFF
"""
start = int(animation.in_point)
end = int(animation.out_point)
frames = []
for i in range(start, end+1):
_log_frame("TIFF", i, end)
file = io.BytesIO()
export_png(animation, file, i, dpi)
file.seek(0)
frames.append(Image.open(file))
_log_frame("TIFF")
io_progress().report_message("TIFF Writing to file...")
duration = int(round(1000 / animation.frame_rate))
frames[0].save(
fp,
format='TIFF',
append_images=frames[1:],
save_all=True,
duration=duration,
loop=0,
dpi=(dpi, dpi),
)
+71
View File
@@ -0,0 +1,71 @@
import sys
from ..objects.base import LottieObject, LottieBase
from ..objects.properties import MultiDimensional, Value, ShapeProperty
from ..objects.layers import Layer
def _prettyprint_scalar(lottie_object, out=sys.stdout):
if isinstance(lottie_object, float) and lottie_object == round(lottie_object):
lottie_object = int(lottie_object)
return str(lottie_object)
def prettyprint(lottie_object, out=sys.stdout, indent=" ", _i=""):
if isinstance(lottie_object, LottieObject):
out.write(lottie_object.__class__.__name__)
out.write('\n')
_i += indent
maxk = max(map(lambda x: len(x.name), lottie_object._props))
for k in lottie_object._props:
out.write(_i)
out.write(k.name.ljust(maxk))
out.write(' : ')
prettyprint(k.get(lottie_object), out, indent, _i)
elif isinstance(lottie_object, (list, tuple)):
if not lottie_object or (not isinstance(lottie_object[0], LottieBase) and len(lottie_object) < 16):
out.write("[")
out.write(", ".join(map(_prettyprint_scalar, lottie_object)))
out.write("]\n")
else:
out.write("[\n")
for k in lottie_object:
out.write(_i + indent)
prettyprint(k, out, indent, _i + indent)
out.write(_i)
out.write(']\n')
else:
out.write(_prettyprint_scalar(lottie_object, out))
out.write('\n')
def _prettyprint_summary_printable(obj):
if isinstance(obj, LottieObject):
return not isinstance(obj, (MultiDimensional, Value, ShapeProperty))
return obj and isinstance(obj, (list, tuple)) and isinstance(obj[0], LottieObject)
def prettyprint_summary(lottie_object, out=sys.stdout, indent=" ", _i=""):
if isinstance(lottie_object, LottieObject):
out.write(lottie_object.__class__.__name__)
name = getattr(lottie_object, "name", None)
if name:
out.write(" %r" % name)
if isinstance(lottie_object, Layer):
out.write(" %s -> %s" % (lottie_object.index, lottie_object.parent_index))
out.write('\n')
_i += indent
for k in lottie_object._props:
val = k.get(lottie_object)
if _prettyprint_summary_printable(val):
out.write(_i)
out.write(k.name)
out.write(' : ')
prettyprint_summary(val, out, indent, _i)
elif _prettyprint_summary_printable(lottie_object):
out.write("[\n")
for k in lottie_object:
out.write(_i + indent)
prettyprint_summary(k, out, indent, _i + indent)
out.write(_i)
out.write(']\n')
+11
View File
@@ -0,0 +1,11 @@
from .base import exporter
from ..parsers.sif.builder import to_sif
from ..utils.file import open_file
@exporter("Synfig", ["sif"], [], {"pretty"})
def export_sif(animation, file, pretty=True):
with open_file(file) as fp:
dom = to_sif(animation).to_xml()
dom.writexml(fp, "", " " if pretty else "", "\n" if pretty else "")
+22
View File
@@ -0,0 +1,22 @@
from xml.dom import minidom
from xml.etree import ElementTree
from .base import exporter
from ..parsers.svg.builder import to_svg
from ..utils.file import open_file
def _print_ugly_xml(dom, file):
return dom.write(file, "utf-8", True)
def _print_pretty_xml(dom, file):
with open_file(file) as fp:
xmlstr = minidom.parseString(ElementTree.tostring(dom.getroot())).toprettyxml(indent=" ")
fp.write(xmlstr)
@exporter("SVG", ["svg"], [], {"pretty", "frame"})
def export_svg(animation, file, frame=0, pretty=True):
_print_xml = _print_pretty_xml if pretty else _print_ugly_xml
_print_xml(to_svg(animation, frame), file)
+198
View File
@@ -0,0 +1,198 @@
import os
import enum
import json
import inspect
from ..parsers.tgs import parse_tgs
from ..objects.base import ObjectVisitor
from ..objects.animation import Animation
from ..objects import layers
from ..objects import shapes
from ..objects import helpers
class Severity(enum.Enum):
Note = enum.auto()
Warning = enum.auto()
Error = enum.auto()
class TgsError:
def __init__(self, message, target, severity=Severity.Warning):
self.message = message
self.target = target
self.severity = severity
def target_id(self):
if isinstance(self.target, str):
return self.target
if getattr(self.target, "name", ""):
return self.target.name
return self.target.__class__.__name__
def __str__(self):
return "%s: on %s: %s" % (
self.severity.name,
self.target_id(),
self.message
)
class TgsValidator(ObjectVisitor):
def __init__(self, severity=Severity.Note):
self.errors = []
self.severity = severity
def _check(self, expr, message, target, severity=Severity.Warning):
if severity.value >= self.severity.value and not expr:
self.errors.append(TgsError(message, target, severity))
def check_file_size(self, filename):
return self.check_size(os.path.getsize(filename))
def check_size(self, bytes, filename="file"):
size_k = bytes / 1024
self._check(
size_k <= 64,
"Invalid size (%.1fk), should be less than 64k" % size_k,
filename,
Severity.Error
)
def check_file(self, filename):
self.check_file_size(filename)
try:
self(parse_tgs(filename))
except json.decoder.JSONDecodeError as e:
self._check(
False,
"Invalid JSON: %s" % e,
filename,
Severity.Error
)
def visit(self, object):
for cls in inspect.getmro(object.__class__):
callback = "_visit_%s" % cls.__name__.lower()
if hasattr(self, callback):
getattr(self, callback)(object)
def _visit_animation(self, o: Animation):
self._check(
o.frame_rate in {30, 60},
"Invalid framerate %s, should be 30 or 60" % o.frame_rate,
o,
Severity.Error
)
self._check(
o.width == 512,
"Invalid width %s, should be 512" % o.width,
o,
Severity.Error
)
self._check(
o.height == 512,
"Invalid height %s, should be 512" % o.height,
o,
Severity.Error
)
self._check(
(o.out_point-o.in_point) <= 180,
"Too many frames (%s), should be less than 180" % (o.out_point-o.in_point),
o,
Severity.Error
)
def _visit_layer(self, o: layers.Layer):
self._check(
not o.has_masks and not o.masks,
"Masks are not officially supported",
o,
Severity.Note
)
self._check(
not o.effects,
"Effects are not supported",
o,
Severity.Warning
)
self._check(
not o.threedimensional,
"3D layers are not supported",
o,
Severity.Warning
)
self._check(
not isinstance(o, layers.TextLayer),
"Text layers are not supported",
o,
Severity.Warning
)
self._check(
not isinstance(o, layers.ImageLayer),
"Image layers are not supported",
o,
Severity.Warning
)
self._check(
not o.auto_orient,
"Auto-orient layers are not supported",
o,
Severity.Warning
)
self._check(
o.matte_mode in {None, layers.MatteMode.Normal},
"Mattes are not supported",
o,
Severity.Warning
)
def _visit_precomplayer(self, o: layers.PreCompLayer):
self._check(
o.time_remapping is None,
"Time remapping is not supported",
o,
Severity.Warning
)
def _visit_merge(self, o: shapes.Merge):
self._check(
False,
"Merge paths are not supported",
o,
Severity.Warning
)
def _visit_transform(self, o: helpers.Transform):
self._check(
o.skew is None or (
not o.skew.animated and o.skew.value == 0
),
"Skew transforms are not supported",
o,
Severity.Warning
)
def _visit_gradientstroke(self, o: shapes.GradientStroke):
self._check(
False,
"Gradient strokes are not officially supported",
o,
Severity.Note
)
def _visit_star(self, o: shapes.Star):
self._check(
False,
"Star Shapes are not officially supported",
o,
Severity.Note
)
def _visit_repeater(self, o: shapes.Repeater):
self._check(
False,
"Repeaters are not officially supported",
o,
Severity.Note
)
+43
View File
@@ -0,0 +1,43 @@
import io
import os
import cv2
import numpy
from PIL import Image
from .cairo import export_png
from .gif import _log_frame
from .base import exporter
from ..parsers.baseporter import ExtraOption
## @see http://www.fourcc.org/codecs.php
formats4cc = {
"avi": cv2.VideoWriter_fourcc(*"XVID"),
"mp4": cv2.VideoWriter_fourcc(*'MP4V'),
#"mp4": cv2.VideoWriter_fourcc(*'X264'),
"webm": cv2.VideoWriter_fourcc(*'VP80'),
}
@exporter("Video", list(formats4cc.keys()), [
ExtraOption("format", default=None, help="Specific video format", choices=list(formats4cc.keys())),
], [], "video")
def export_video(animation, fp, format=None):
start = int(animation.in_point)
end = int(animation.out_point)
if format is None:
fn = fp if isinstance(fp, str) else fp.name
format = os.path.splitext(fn)[1][1:]
fmt = formats4cc[format]
video = cv2.VideoWriter(fp, fmt, animation.frame_rate, (animation.width, animation.height))
for i in range(start, end+1):
_log_frame(format, i, end)
file = io.BytesIO()
export_png(animation, file, i)
file.seek(0)
video.write(cv2.cvtColor(numpy.array(Image.open(file)), cv2.COLOR_RGB2BGR))
_log_frame(format)
video.release()