Skip to content

Instantly share code, notes, and snippets.

@mbasaglia
Last active February 11, 2024 21:18
Show Gist options
  • Save mbasaglia/5e82cbbe64bbbd3ce96aadb0f952b1e9 to your computer and use it in GitHub Desktop.
Save mbasaglia/5e82cbbe64bbbd3ce96aadb0f952b1e9 to your computer and use it in GitHub Desktop.
Testing shape instructions for lottie-spec
# pip install lottie cairosvg
import io
import re
import sys
import enum
import math
import json
import lottie
import pathlib
import argparse
class RenderContext:
def __init__(self):
self.values = {}
self.shape = lottie.objects.bezier.Bezier()
self.shape.closed = True
self.inputs = set()
self.members = {}
self.define_value("E_t", 0.5519150244935105707435627)
def define_value(self, name, value):
self.values[name] = value
def add_vertex(self, vertex: lottie.NVector):
self.shape.add_point(vertex)
class AstNode:
def evaluate(self, context: RenderContext):
raise NotImplementedError
def get_inputs(self, context: RenderContext):
return
class AstComment(AstNode):
def __init__(self, comment):
self.comment = comment
def evaluate(self, context: RenderContext):
pass
class AstBinaryNode(AstNode):
def __init__(self, lhs: AstNode, rhs: AstNode):
self.lhs = lhs
self.rhs = rhs
def get_inputs(self, context: RenderContext):
self.lhs.get_inputs(context)
self.rhs.get_inputs(context)
class AstUnaryNode(AstNode):
def __init__(self, child: AstNode):
self.child = child
def get_inputs(self, context: RenderContext):
self.child.get_inputs(context)
class AstBinOp(AstBinaryNode):
def __init__(self, lhs: AstNode, rhs: AstNode, op: str):
super().__init__(lhs, rhs)
match op:
case "cdot" | "times":
op = "*"
case "=":
op = "=="
self.op = op
def evaluate(self, context: RenderContext):
lhs = self.lhs.evaluate(context)
rhs = self.rhs.evaluate(context)
match self.op:
case "+":
return lhs + rhs
case "-":
return lhs - rhs
case "*":
return lhs * rhs
case "/":
return lhs / rhs
case ">":
return lhs > rhs
case "<":
return lhs < rhs
case "==":
return lhs == rhs
raise Exception("Unknown operator %s" % self.op)
class AstUnOp(AstUnaryNode):
def __init__(self, child: AstNode, op: str):
super().__init__(child)
self.op = op
def evaluate(self, context: RenderContext):
child = self.child.evaluate(context)
match self.op:
case "+":
return child
case "-":
return -child
case "floor":
return math.floor(child)
case "ceil":
return math.ceil(child)
raise Exception("Unknown operator %s" % self.op)
class AstStep(AstUnaryNode):
def __init__(self, child: AstNode, op: str):
super().__init__(child)
self.op = op
def evaluate(self, context: RenderContext):
child = self.child.evaluate(context)
match self.op:
case "bez_v":
context.add_vertex(child)
return None
case "bez_i":
context.shape.in_tangents[-1] = child
return None
case "bez_o":
context.shape.out_tangents[-1] = child
return None
raise Exception("Unknown operator %s" % self.op)
class AstLiteral(AstNode):
def __init__(self, value):
self.value = value
def evaluate(self, context: RenderContext):
return self.value
class AstPoint(AstBinaryNode):
def evaluate(self, context: RenderContext):
lhs = self.lhs.evaluate(context)
rhs = self.rhs.evaluate(context)
return lottie.Point(lhs, rhs)
class AstNaryNode(AstNode):
def __init__(self, operands):
self.operands = operands
def get_inputs(self, context: RenderContext):
for o in self.operands:
o.get_inputs(context)
class AstNaryOp(AstNaryNode):
def __init__(self, operands, op):
super().__init__(operands)
self.op = op
def evaluate(self, context: RenderContext):
operands = [o.evaluate(context) for o in self.operands]
match self.op:
case "min":
return min(operands)
case "max":
return max(operands)
raise Exception("Unknown operator %s" % self.op)
class AstBlock(AstNaryNode):
def __init__(self, type):
super().__init__([])
self.type = type
def evaluate(self, context: RenderContext):
for o in self.operands:
o.evaluate(context)
class AstNamedValue(AstNode):
def __init__(self, name: str):
self.name = name
if "." in self.name:
self.object, self.member = self.name.split(".")
else:
self.object = name
self.member = None
def evaluate(self, context: RenderContext):
if self.member and self.name not in context.values:
val = context.values[self.object]
return getattr(val, self.member)
return context.values[self.name]
def get_inputs(self, context: RenderContext):
if self.object not in context.values:
context.inputs.add(self.name)
if self.member:
if self.object not in context.members:
context.members[self.object] = {self.member}
else:
context.members[self.object].add(self.member)
class AstDefinition(AstUnaryNode):
def __init__(self, child: AstNode, name: str):
super().__init__(child)
self.name = name
def evaluate(self, context: RenderContext):
context.values[self.name] = self.child.evaluate(context)
def get_inputs(self, context: RenderContext):
context.define_value(self.name, None)
self.child.get_inputs(context)
class AstCondition(AstBinaryNode):
def evaluate(self, context):
if self.lhs.evaluate(context):
self.rhs.evaluate(context)
class TokenType(enum.Enum):
Operator = enum.auto()
Literal = enum.auto()
Lparen = enum.auto()
Rparen = enum.auto()
Lbrace = enum.auto()
Rbrace = enum.auto()
Name = enum.auto()
Ignored = enum.auto()
Eof = enum.auto()
SlashName = enum.auto()
Comma = enum.auto()
Step = enum.auto()
MathInline = enum.auto()
MathBlock = enum.auto()
class Token:
def __init__(self, type: TokenType, value=None):
self.type = type
self.value = value
class InstructionParser:
def __init__(self, stream):
self.stream = stream
self.lookahead = Token(TokenType.Eof)
def lex_getch_nospace(self) -> str:
while True:
c = self.stream.read(1)
if not c.isspace():
return c
def lex_getch(self) -> str:
return self.stream.read(1)
def lex_unget(self):
self.stream.seek(self.stream.tell() - 1)
def lex_token(self) -> Token:
while True:
c = self.lex_getch_nospace()
if c == '' or c not in "&":
break
if c == '':
return Token(TokenType.Eof)
if c == "$":
d = self.lex_getch()
if d == "$":
return Token(TokenType.MathBlock)
self.lex_unget()
return Token(TokenType.MathInline)
if c == '\\':
d = self.lex_getch()
if d == '\\':
return self.lex_token()
if d.isalpha():
name = ""
while d.isalpha():
name += d
d = self.lex_getch()
self.lex_unget()
if name in ("left", "right"):
return self.lex_token()
if name in ("begin", "end"):
while True:
c = self.lex_getch()
if c == '}':
return self.lex_token()
return Token(TokenType.SlashName, name)
if c in "*+-=<>":
return Token(TokenType.Operator, c)
if c.isalpha():
name = ""
while c.isalpha() or c in "_.":
name += c
c = self.lex_getch()
self.lex_unget()
return Token(TokenType.Name, name)
if c == "{":
return Token(TokenType.Lbrace)
if c == "}":
return Token(TokenType.Rbrace)
if c == "(":
return Token(TokenType.Lparen)
if c == ")":
return Token(TokenType.Rparen)
if c == ",":
return Token(TokenType.Comma)
# Skip markdown images
if c == "!":
d = self.lex_getch()
if d == "[":
while c != ")":
c = self.lex_getch()
return self.lex()
self.lex_unget()
if c.isdigit():
val = ""
while c.isdigit() or c == ".":
val += c
c = self.lex_getch()
self.lex_unget()
if val.endswith("."):
return Token(TokenType.Step)
return Token(TokenType.Literal, float(val))
raise Exception("Unknown token %s" % c)
def lex(self):
self.lookahead = self.lex_token()
# print(self.stream.tell(), self.lookahead.type, self.lookahead.value)
return self.lookahead
def parse(self):
try:
self.lex()
block = AstBlock("top")
while self.lookahead.type != TokenType.Eof:
block.operands.append(self.parse_statement(False))
return block
except Exception:
pos = self.stream.tell()
endl = pos
while self.stream.read(1) not in ("", "\n"):
endl += 1
self.stream.seek(0)
before = self.stream.read(endl)
startl = before.rfind("\n") + 1
col = pos - startl
row = before.count("\n")
sys.stderr.write("Error at position %s. Line %s Col %s\n" % (pos, row, col))
sys.stderr.write(before[startl:endl])
sys.stderr.write("\n%s^\n" % (" " * (pos-startl)))
raise
def parse_statement(self, in_condition):
if self.lookahead.type == TokenType.Step:
block = AstBlock("steps")
while self.lookahead.type == TokenType.Step:
block.operands.append(self.parse_step())
return block if block.operands != 1 else block.operands[0]
elif self.lookahead.type == TokenType.MathBlock:
self.lex()
block = AstBlock("math")
while self.lookahead.type != TokenType.MathBlock:
block.operands.append(self.parse_assignment())
self.lex()
return block if block.operands != 1 else block.operands[0]
elif self.lookahead.type == TokenType.Name:
comment = self.lookahead.value
while True:
c = self.lex_getch()
if c == '' or c == '\n':
self.lex()
return AstComment(comment)
if comment == "If":
pos = self.stream.tell()
if in_condition:
self.stream.seek(pos - 2)
return None
if self.lex().type == TokenType.MathInline:
self.lex()
return self.parse_condition_block()
else:
self.stream.seek(pos)
comment += c
else:
self.expect(TokenType.Eof)
def parse_condition_block(self):
block = AstBlock("if")
condition = AstCondition(self.parse_expression(), block)
self.expect(TokenType.MathInline)
comment = ""
while True:
c = self.lex_getch()
if c == '' or c == '\n':
self.lex()
break
comment += c
comment = comment.strip(" .,")
if comment:
block.operands.append(AstComment(comment))
while True:
stmt = self.parse_statement(True)
if not stmt:
break
block.operands.append(stmt)
return condition
def parse_step(self):
instruction = ""
c = self.lex_getch_nospace()
while True:
if c == '' or c == '\n':
return AstComment(instruction)
instruction += c
match instruction:
case "Add vertex":
self.lex()
return AstStep(self.parse_point(), "bez_v")
case "Set in tangent":
self.lex()
return AstStep(self.parse_point(), "bez_i")
case "Set out tangent":
self.lex()
return AstStep(self.parse_point(), "bez_o")
c = self.lex_getch()
def parse_assignment(self):
name = self.expect(TokenType.Name).value
self.expect(TokenType.Operator, "=")
value = self.parse_expression()
return AstDefinition(value, name)
def parse_point(self):
self.expect(TokenType.MathInline)
self.expect(TokenType.Lparen)
x = self.parse_expression()
self.expect(TokenType.Comma)
y = self.parse_expression()
self.expect(TokenType.Rparen)
self.expect(TokenType.MathInline)
return AstPoint(x, y)
def parse_expression(self):
return self.parse_cmp()
def has_operator(self, *op):
return self.lookahead.type == TokenType.Operator and self.lookahead.value in op
def has_slashname(self, *op):
return self.lookahead.type == TokenType.SlashName and self.lookahead.value in op
def parse_cmp(self):
lhs = self.parse_add()
while self.has_operator(">", "<", "="):
op = self.lookahead.value
self.lex()
rhs = self.parse_add()
lhs = AstBinOp(lhs, rhs, op)
return lhs
def parse_add(self):
lhs = self.parse_mul()
while self.has_operator("+", "-") or self.has_slashname("cdot", "times"):
op = self.lookahead.value
self.lex()
rhs = self.parse_mul()
lhs = AstBinOp(lhs, rhs, op)
return lhs
def parse_mul(self):
lhs = self.parse_unary()
while self.has_operator("*", "/"):
op = self.lookahead.value
self.lex()
rhs = self.parse_unary()
lhs = AstBinOp(lhs, rhs, op)
return lhs
def parse_unary(self):
if self.lookahead.type == TokenType.Operator:
op = self.lookahead.value
self.lex()
return AstUnOp(self.parse_expression(), op)
if self.lookahead.type == TokenType.SlashName:
name = self.lookahead.value
if name in ("lfloor", "lceil"):
self.lex()
child = self.parse_expression()
close = self.expect(TokenType.SlashName).value
if close[1:] != name[1:]:
raise Exception("%s after %s" % (close, name))
return AstUnOp(child, name[1:])
elif name == "frac":
self.lex()
self.expect(TokenType.Lbrace)
lhs = self.parse_expression()
self.expect(TokenType.Rbrace)
self.expect(TokenType.Lbrace)
rhs = self.parse_expression()
self.expect(TokenType.Rbrace)
return AstBinOp(lhs, rhs, "/")
elif name in ("min", "max"):
self.lex()
self.expect(TokenType.Lparen)
operands = []
while True:
operands.append(self.parse_expression())
if self.lookahead.type == TokenType.Rparen:
self.lex()
break
self.expect(TokenType.Comma)
return AstNaryOp(operands, name)
if self.lookahead.type == TokenType.Lparen:
self.lex()
child = self.parse_expression()
self.expect(TokenType.Rparen)
return child
return self.parse_primary()
def parse_primary(self):
if self.lookahead.type == TokenType.Literal:
node = AstLiteral(self.lookahead.value)
self.lex()
return node
elif self.lookahead.type == TokenType.Name:
node = AstNamedValue(self.lookahead.value)
self.lex()
return node
self.raise_token()
def expect(self, type, value=None):
if self.lookahead.type != type:
self.raise_token(type)
if value is not None:
if self.lookahead.value != value:
raise Exception("Expected token value %r, got %r" % (value, self.lookahead.value))
tok = self.lookahead
self.lex()
return tok
def raise_token(self, expected=None):
msg = "Unknown token %s %s" % (self.lookahead.type, self.lookahead.value)
if expected:
msg += ". Expected %s" % expected
raise Exception(msg)
def ast_to_python(node: AstNode, indent=""):
match node:
case AstComment():
return "%s# %s\n" % (indent, node.comment)
case AstBinOp():
return "(%s %s %s)" % (ast_to_python(node.lhs), node.op, ast_to_python(node.rhs))
case AstUnOp():
return "%s%s" % (node.op, ast_to_python(node.child))
case AstLiteral():
return repr(node.value)
case AstPoint():
return "NVector(%s, %s)" % (ast_to_python(node.lhs), ast_to_python(node.rhs))
case AstNaryOp():
return "%s(%s)" % (node.op, ", ".join(map(ast_to_python, node.operands)))
case AstBlock():
return "\n".join(ast_to_python(p, indent) for p in node.operands) + "\n"
case AstNamedValue():
return node.name
case AstDefinition():
return "%s%s = %s" % (indent, node.name, ast_to_python(node.child, indent))
case AstStep(op="bez_v"):
return "%sshape.add_point(%s)" % (indent, ast_to_python(node.child))
case AstStep(op="bez_i"):
return "%sshape.in_tangents[-1] = %s" % (indent, ast_to_python(node.child))
case AstStep(op="bez_o"):
return "%sshape.out_tangents[-1] = %s" % (indent, ast_to_python(node.child))
case AstCondition():
next_indent = indent + (" " * 4)
return "%sif %s:\n%s" % (indent, ast_to_python(node.lhs), ast_to_python(node.rhs, next_indent))
case _:
raise Exception("Unknown node %s" % node)
def ast_to_json(node):
if isinstance(node, AstNode):
data = {"_": type(node).__name__}
for k, v in vars(node).items():
data[k] = ast_to_json(v)
return data
elif isinstance(node, list):
return [ast_to_json(v) for v in node]
return node
class InputReader:
def __init__(self, context: RenderContext, defaults: dict):
self.context = context
for key, val in defaults.items():
if isinstance(val, list):
val = lottie.NVector(*val)
context.define_value(key, val)
context.inputs.add(key)
def get_value(self, var):
hint = ""
if self.context.members.get(var, set()) & {"x", "y"}:
hint = " (point)"
strval = input("%s%s: " % (var, hint))
if "," in strval:
return lottie.NVector(*map(float, strval.split(",")))
else:
return float(strval)
def resolve(self):
for var in sorted(context.inputs):
if var not in context.values:
context.define_value(var, self.get_value(var))
def render_lottie(context: RenderContext):
ast.evaluate(context)
an = lottie.objects.Animation()
lay = lottie.objects.ShapeLayer()
an.add_layer(lay)
path = lottie.objects.Path(context.shape)
bbox = path.bounding_box()
margin = 10
an.width = bbox.width + 2 * margin
an.height = bbox.height + 2 * margin
lay.shapes.append(path)
lay.shapes.append(lottie.objects.Stroke(lottie.Color(1, 0, 0), 4))
lay.transform.position.value = -lottie.NVector(bbox.x1 - margin, bbox.y1 - margin)
return an
def render_python(context: RenderContext, fp):
fp.write("import lottie\n")
fp.write("from lottie import NVector\n")
cleaned_inputs = set()
for input in context.inputs:
cleaned_inputs.add(input)
if "." in input:
cleaned_inputs.add(input.split(".")[0])
input_str = "\n# Inputs\n"
needs_mock = False
for input in sorted(cleaned_inputs):
if input in context.values:
input_str += "%s = %r\n" % (input, context.values[input])
else:
input_str += "%s = unittest.mock.MagicMock()\n" % input
needs_mock = True
if needs_mock:
fp.write("import unittest.mock\n")
fp.write(input_str)
fp.write("\nE_t = 0.5519150244935105707435627\n\n")
fp.write("shape = lottie.objects.bezier.Bezier()\nshape.closed = True\n\n")
fp.write(ast_to_python(ast))
class File:
def __init__(self, file):
self.file = sys.stdout if file is True else open(file, "w")
def __enter__(self):
return self.file
def __exit__(self, *a):
if self.file is not sys.stdout:
self.file.close()
if __name__ == "__main__":
arg_parser = argparse.ArgumentParser()
file_arg = dict(nargs="?", type=pathlib.Path, const=True)
arg_parser.add_argument("--shape", "-s", default="rectangle")
arg_parser.add_argument("--json", "-j", **file_arg)
arg_parser.add_argument("--python", "-py", **file_arg)
arg_parser.add_argument("--lottie", "-l", **file_arg)
arg_parser.add_argument("--png", type=pathlib.Path)
arg_parser.add_argument("--defaults", "-d", type=str, default="")
args = arg_parser.parse_args()
shapes_md_path = pathlib.Path(__file__).absolute().parent.parent / "docs" / "specs" / "shapes.md"
with open(shapes_md_path) as file:
shapes_md = file.read()
start = re.search("<h[0-9][^>]*id=['\"]%s['\"]" % args.shape, shapes_md).end()
shapes_md = shapes_md[start:]
start = re.search("lottie-playground>", shapes_md).end()
shapes_md = shapes_md[start:]
end = re.search("^<h[0-9]", shapes_md, re.MULTILINE).start()
stream = io.StringIO(shapes_md[:end])
defaults = {}
if args.defaults.startswith("{"):
defaults = json.loads(args.defaults)
elif args.defaults:
with open(args.defaults) as f:
defaults = json.load(f)
parser = InstructionParser(stream)
ast = parser.parse()
if args.json:
with File(args.json) as f:
f.write(json.dumps(ast_to_json(ast), indent=4) + "\n")
context = RenderContext()
inputs = InputReader(context, defaults)
ast.get_inputs(context)
if args.lottie:
inputs.resolve()
an = render_lottie(context)
with File(args.lottie) as f:
f.write(json.dumps(an.to_dict(), indent=4) + "\n")
else:
an = None
if args.png:
if an is None:
inputs.resolve()
an = render_lottie(context)
with open(args.png, "wb") as fp:
lottie.exporters.cairo.export_png(an, fp)
if args.python:
with File(args.python) as f:
render_python(context, f)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment