Blob Blame Raw
"""
Python plugin to convert the .sif format into lottie json format
input   : FILE_NAME.sif
output  : FILE_NAME.json

Currently working for only star layers without animations
Partially working for animations
"""
import xml.etree.ElementTree as ET
import json
import sys

class Count:
    """
    Class to keep count of variable
    """
    def __init__(self):
        self.idx = -1
    def inc(self):
        """
        This method increases the count by 1 and returns the new count
        """
        self.idx += 1
        return self.idx

# Final converted dictionary
lottie_format = {}
view_box_canvas = {}

# Constants
LOTTIE_VERSION = "5.3.4"
IN_POINT = 0
OUT_POINT = 1.00000004073083
DEFAULT_WIDTH = 480
DEFAULT_HEIGHT = 270
DEFAULT_NAME = "Synfig Animation"
DEFAULT_3D = 0
DEFAULT_BLEND = 0
LAYER_SHAPE_TYPE = 4
LAYER_SHAPE_NAME = "Shape Layer "
LAYER_DEFAULT_STRETCH = 1
LAYER_DEFAULT_AUTO_ORIENT = 0
OPACITY_CONSTANT = 100
DEFAULT_ANIMATED = 0
NO_INFO = "no_info"
DEFAULT_ROTATION = 0
DEFAULT_OPACITY = 100
GAMMA = 2.2
PIX_PER_UNIT = 0
TANGENT_FACTOR = 3

def gen_canvas(lottie, root):
    """
    Generates the canvas for the lottie format
    It is the outer most dictionary in the lottie json format
    """
    view_box_canvas["val"] = [float(itr)
                              for itr in root.attrib["view-box"].split()]
    if "width" in root.attrib.keys():
        lottie["w"] = int(root.attrib["width"])
    else:
        lottie["w"] = DEFAULT_WIDTH

    if "height" in root.attrib.keys():
        lottie["h"] = int(root.attrib["height"])
    else:
        lottie["h"] = DEFAULT_HEIGHT

    name = DEFAULT_NAME
    for child in root:
        if child.tag == "name":
            name = child.text
            break
    lottie["nm"] = name
    lottie["ddd"] = DEFAULT_3D
    lottie["v"] = LOTTIE_VERSION
    lottie["fr"] = float(root.attrib["fps"])
    lottie["ip"] = float(root.attrib["begin-time"][:-1])
    lottie["op"] = float(root.attrib["end-time"][:-1]) * lottie["fr"]
    calculate_pixels_per_unit()

def gen_properties_value(lottie, val, index, animated, expression):
    """
    Generates the dictionary corresponding to properties/value.json in lottie
    documentation and properties/multidimensional.json
    """
    lottie["k"] = val
    lottie["ix"] = index
    lottie["a"] = animated
    if expression != NO_INFO:
        lottie["x"] = expression

def change_axis(x_val, y_val):
    """
    Convert synfig axis coordinates into lottie format
    x_val and y_val should be in pixels
    """
    x_val, y_val = float(x_val), float(y_val)
    x_val, y_val = x_val + lottie_format["w"]/2, -y_val + lottie_format["h"]/2
    return [int(x_val), int(y_val)]

def gen_helpers_transform(lottie, layer):
    """
    Generates the dictionary corresponding to helpers/transform.json
    """
    index = Count()
    lottie["o"] = {}    # opacity/Amount
    lottie["r"] = {}    # Rotation of the layer
    lottie["p"] = {}    # Position of the layer
    lottie["a"] = {}    # Anchor point of the layer
    lottie["s"] = {}    # Scale of the layer
    for child in layer:
        if child.tag == "param":
            if child.attrib["name"] == "amount":
                val = int(OPACITY_CONSTANT * float(child[0].attrib["value"]))
                gen_properties_value(
                    lottie["o"], val, index.inc(), DEFAULT_ANIMATED, NO_INFO)
            elif child.attrib["name"] == "origin":
                if child[0].tag == "vector":
                    x_val = float(child[0][0].text) * PIX_PER_UNIT
                    y_val = float(child[0][1].text) * PIX_PER_UNIT
                    gen_properties_value(lottie["p"], change_axis(x_val, y_val),
                                         index.inc(), DEFAULT_ANIMATED, NO_INFO)
                else:
                    gen_properties_value(lottie["p"], [0, 0],
                                         index.inc(), DEFAULT_ANIMATED, NO_INFO)

    gen_properties_value(
        lottie["r"],
        DEFAULT_ROTATION,
        index.inc(),
        DEFAULT_ANIMATED,
        NO_INFO)
    gen_properties_value(
        lottie["a"], [
            0, 0, 0], index.inc(), DEFAULT_ANIMATED, NO_INFO)
    gen_properties_value(
        lottie["s"], [
            100, 100, 100], index.inc(), DEFAULT_ANIMATED, NO_INFO)

def calculate_pixels_per_unit():
    """
    Gives the value of 1 unit in terms of pixels according to the canvas defined
    """
    image_width = float(lottie_format["w"])
    image_area_width = view_box_canvas["val"][2] - view_box_canvas["val"][0]
    global PIX_PER_UNIT
    PIX_PER_UNIT = image_width / image_area_width
    return PIX_PER_UNIT

def get_angle(theta):
    """
    Converts the .sif angle into lottie angle
    .sif uses positive x-axis as the start point and goes anticlockwise
    lottie uses positive y-axis as the start point and goes clockwise
    """
    theta = int(theta)
    theta = theta % 360
    if theta < 90:
        theta = 90 - theta
    else:
        theta = 450 - theta
    theta = theta % 360
    return theta

def parse_position(animated, i):
    """
    To convert the synfig coordinates from units(initially a string) to pixels
    """
    pos = [float(animated[i][0][0].text),
           float(animated[i][0][1].text)]
    pos = [PIX_PER_UNIT*x for x in pos]
    return pos

def gen_properties_offset_keyframe(lottie, animated, i):
    """
     Generates the dictionary corresponding to properties/offsetKeyFrame.json
    """
    waypoint, next_waypoint = animated[i], animated[i+1]
    # Calculate positions of waypoints
    cur_pos = parse_position(animated, i)
    prev_pos = cur_pos
    next_pos = parse_position(animated, i + 1)
    next_next_pos = next_pos
    if i + 2 <= len(animated) - 1:
        next_next_pos = parse_position(animated, i + 2)
    if i - 1 >= 0:
        prev_pos = parse_position(animated, i - 1)

    lottie["i"] = {}    # Time bezier curve, not used in synfig
    lottie["o"] = {}    # Time bezier curve, not used in synfig
    lottie["i"]["x"] = 0.5
    lottie["i"]["y"] = 0.5
    lottie["o"]["x"] = 0.5
    lottie["o"]["y"] = 0.5
    lottie["t"] = float(waypoint.attrib["time"][:-1]) * lottie_format["fr"]
    lottie["s"] = change_axis(cur_pos[0], cur_pos[1])
    lottie["e"] = change_axis(next_pos[0], next_pos[1])
    tens, bias, cont = 0, 0, 0   # default values
    tens1, bias1, cont1 = 0, 0, 0
    if "tension" in waypoint.keys():
        tens = float(waypoint.attrib["tension"])
    if "continuity" in waypoint.keys():
        cont = float(waypoint.attrib["continuity"])
    if "bias" in waypoint.keys():
        bias = float(waypoint.attrib["bias"])
    if "tension" in next_waypoint.keys():
        tens1 = float(next_waypoint.attrib["tension"])
    if "continuity" in next_waypoint.keys():
        cont1 = float(next_waypoint.attrib["continuity"])
    if "bias" in next_waypoint.keys():
        bias1 = float(next_waypoint.attrib["bias"])
    lottie["to"] = []
    lottie["ti"] = []
    for dim in range(len(cur_pos)):
        if i >= 1:
            out_val = ((1 - tens) * (1 + bias) * (1 + cont) *\
                       (cur_pos[dim] - prev_pos[dim]))/2 +\
                       ((1 - tens) * (1 - bias) * (1 - cont) *\
                       (next_pos[dim] - cur_pos[dim]))/2
        else:
            out_val = next_pos[dim] - cur_pos[dim]      # t1 = p2 - p1
        if i + 2 <= len(animated) - 1:
            in_val = ((1 - tens1) * (1 + bias1) * (1 - cont1) *\
                      (next_pos[dim] - cur_pos[dim]))/2 +\
                      ((1 - tens1) * (1 - bias1) * (1 + cont1) *\
                      (next_next_pos[dim] - next_pos[dim]))/2
        else:
            in_val = next_pos[dim] - cur_pos[dim]       # t2 = p2 - p1
        lottie["to"].append(out_val)
        lottie["ti"].append(in_val)

    # Lottie and synfig use different tangents SEE DOCUMENTATION
    lottie["ti"] = [-item for item in lottie["ti"]]

    # Lottie tangent length is larger than synfig
    lottie["ti"] = [item/TANGENT_FACTOR for item in lottie["ti"]]
    lottie["to"] = [item/TANGENT_FACTOR for item in lottie["to"]]

    # IMPORTANT to and ti have to be relative
    # The y-axis is different in lottie
    lottie["ti"][1] = -lottie["ti"][1]
    lottie["to"][1] = -lottie["to"][1]


def gen_properties_multi_dimensional_keyframed(lottie, animated, idx):
    """
    Generates the dictionary corresponding to
    properties/multiDimensionalKeyframed.json
    """
    lottie["a"] = 1
    lottie["ix"] = idx
    lottie["k"] = []
    for i in range(len(animated) - 1):
        lottie["k"].append({})
        gen_properties_offset_keyframe(lottie["k"][-1], animated, i)
    last_waypoint_time = float(animated[-1].attrib["time"][:-1]) * lottie_format["fr"]
    lottie["k"].append({})
    lottie["k"][-1]["t"] = last_waypoint_time

    # Time adjust of the curves
    timeadjust = 0.5
    for i in range(len(animated) - 1):
        if i == 0:
            continue
        time_span_cur = lottie["k"][i+1]["t"] - lottie["k"][i]["t"]
        time_span_prev = lottie["k"][i]["t"] - lottie["k"][i-1]["t"]
        for dim in range(len(lottie["k"][i]["to"])):
            lottie["k"][i]["to"][dim] = lottie["k"][i]["to"][dim] *\
            (time_span_cur * (timeadjust + 1)) /\
            (time_span_cur * timeadjust + time_span_prev)

            if i + 2 <= len(animated) - 1:
                time_span_next = lottie["k"][i+2]["t"] - lottie["k"][i+1]["t"]
                lottie["k"][i]["ti"][dim] = lottie["k"][i]["ti"][dim] *\
                (time_span_cur * (timeadjust + 1)) /\
                (time_span_cur * timeadjust + time_span_next)



def gen_shapes_star(lottie, layer, idx):
    """
    Generates the dictionary corresponding to shapes/star.json
    """
    index = Count()
    lottie["ty"] = "sr"     # Type: star
    lottie["pt"] = {}       # Number of points on the star
    lottie["p"] = {}        # Position of star
    lottie["r"] = {}        # Angle / Star's rotation
    lottie["ir"] = {}       # Inner radius
    lottie["or"] = {}       # Outer radius
    lottie["is"] = {}       # Inner roundness of the star
    lottie["os"] = {}       # Outer roundness of the star
    regular_polygon = "false"
    for child in layer:
        if child.tag == "param":
            if child.attrib["name"] == "regular_polygon":
                regular_polygon = child[0].attrib["value"]
            elif child.attrib["name"] == "points":
                gen_properties_value(lottie["pt"],
                                     int(child[0].attrib["value"]),
                                     index.inc(),
                                     DEFAULT_ANIMATED,
                                     NO_INFO)
            elif child.attrib["name"] == "angle":
                theta = get_angle(float(child[0].attrib["value"]))
                gen_properties_value(
                    lottie["r"], theta, index.inc(), DEFAULT_ANIMATED, NO_INFO)
            elif child.attrib["name"] == "radius1":
                r_outer = float(child[0].attrib["value"])
                gen_properties_value(
                    lottie["or"], int(
                        PIX_PER_UNIT * r_outer), index.inc(), DEFAULT_ANIMATED, NO_INFO)
            elif child.attrib["name"] == "radius2":
                r_inner = float(child[0].attrib["value"])
                gen_properties_value(
                    lottie["ir"], int(
                        PIX_PER_UNIT * r_inner), index.inc(), DEFAULT_ANIMATED, NO_INFO)
            elif child.attrib["name"] == "origin":
                if child[0].tag == "animated":
                    gen_properties_multi_dimensional_keyframed(lottie["p"],
                                                             child[0], index.inc())
                else:
                    gen_properties_value(lottie["p"], [0, 0],
                                         index.inc(), DEFAULT_ANIMATED, NO_INFO)

    if regular_polygon == "false":
        lottie["sy"] = 1    # Star Type
    else:
        lottie["sy"] = 2    # Polygon Type
    gen_properties_value(lottie["is"], 0, index.inc(), DEFAULT_ANIMATED, NO_INFO)
    gen_properties_value(lottie["os"], 0, index.inc(), DEFAULT_ANIMATED, NO_INFO)
    lottie["ix"] = idx

def gen_shapes_fill(lottie, layer):
    """
    Generates the dictionary corresponding to shapes/fill.json
    """
    index = Count()
    lottie["ty"] = "fl"     # Type if fill
    lottie["c"] = {}       # Color
    lottie["o"] = {}       # Opacity of the fill layer
    for child in layer:
        if child.tag == "param":
            if child.attrib["name"] == "color":
                red = float(child[0][0].text)
                green = float(child[0][1].text)
                blue = float(child[0][2].text)
                red, green, blue = red ** (1/GAMMA), green ** (1/GAMMA), blue ** (1/ GAMMA)
                a_val = child[0][3].text
                gen_properties_value(
                    lottie["c"], [
                        red, green, blue, a_val], index.inc(), DEFAULT_ANIMATED, NO_INFO)

    gen_properties_value(
        lottie["o"],
        DEFAULT_OPACITY,
        index.inc(),
        DEFAULT_ANIMATED,
        NO_INFO)

def gen_layer_shape(lottie, layer, idx):
    """
    Generates the dictionary corresponding to layers/shape.json
    """
    index = Count()
    lottie["ddd"] = DEFAULT_3D
    lottie["ind"] = idx
    lottie["ty"] = LAYER_SHAPE_TYPE
    lottie["nm"] = LAYER_SHAPE_NAME + str(idx)
    lottie["sr"] = LAYER_DEFAULT_STRETCH
    lottie["ks"] = {}   # Transform properties to be filled
    gen_helpers_transform(lottie["ks"], layer)

    lottie["ao"] = LAYER_DEFAULT_AUTO_ORIENT
    lottie["shapes"] = []   # Shapes to be filled yet
    lottie["shapes"].append({})
    if layer.attrib["type"] == "star":
        gen_shapes_star(lottie["shapes"][0], layer, index.inc())
    lottie["shapes"].append({})  # For the fill or color
    gen_shapes_fill(lottie["shapes"][1], layer)

    lottie["ip"] = lottie_format["ip"]
    lottie["op"] = lottie_format["op"]
    lottie["st"] = 0            # Don't know yet
    lottie["bm"] = DEFAULT_BLEND
    lottie["markers"] = []      # Markers to be filled yet

if len(sys.argv) < 2:
    sys.exit()
else:
    FILE_NAME = sys.argv[1]
    tree = ET.parse(FILE_NAME)
    root = tree.getroot()  # canvas
    gen_canvas(lottie_format, root)

    num_layers = Count()
    lottie_format["layers"] = []
    for child in root:
        if child.tag == "layer":
            if child.attrib["type"] != "star":  # Only star conversion
                continue
            lottie_format["layers"].insert(0, {})
            gen_layer_shape(
                lottie_format["layers"][0],
                child,
                num_layers.inc())

    lottie_string = json.dumps(lottie_format)
    # Write the output to the file name with .json extension
    NEW_FILE_NAME = FILE_NAME.split(".")
    NEW_FILE_NAME = NEW_FILE_NAME[:-2]
    NEW_FILE_NAME[-1] = "json"
    NEW_FILE_NAME = ".".join(NEW_FILE_NAME)
    outputfile_f = open(NEW_FILE_NAME, 'w')
    outputfile_f.write(lottie_string)
    outputfile_f.close()