feat: анимации с реанимации

This commit is contained in:
Jganenokk
2026-08-06 19:21:45 +07:00
parent 81159bac60
commit 79c743e86a
50 changed files with 3247 additions and 925 deletions
+660
View File
@@ -0,0 +1,660 @@
"""Собирает lottie-морфы иконок композера прямо из шрифта Material Symbols.
Контуры глифов берутся из MaterialSymbolsOutlined.ttf (инстанс по умолчанию —
FILL 0, GRAD 0, opsz 24, wght 400, то есть ровно то, что рисует Icon в приложении),
разбиваются на равное число безье-сегментов и попарно сопоставляются, чтобы
lottie мог интерполировать один глиф в другой.
python3 tool/make_morph_icons.py
Пересобирать нужно после обновления material_symbols_icons.
"""
import json
import math
import os
import struct
class Font:
def __init__(self, path):
self.data = open(path, 'rb').read()
self.tables = {}
num_tables = struct.unpack('>H', self.data[4:6])[0]
for i in range(num_tables):
off = 12 + i * 16
tag = self.data[off:off + 4].decode('latin1')
t_off, t_len = struct.unpack('>II', self.data[off + 8:off + 16])
self.tables[tag] = (t_off, t_len)
head_off = self.tables['head'][0]
self.units_per_em = struct.unpack(
'>H', self.data[head_off + 18:head_off + 20])[0]
self.index_to_loc = struct.unpack(
'>h', self.data[head_off + 50:head_off + 52])[0]
maxp_off = self.tables['maxp'][0]
self.num_glyphs = struct.unpack(
'>H', self.data[maxp_off + 4:maxp_off + 6])[0]
self._read_loca()
self._read_cmap()
def _read_loca(self):
off, _ = self.tables['loca']
n = self.num_glyphs + 1
if self.index_to_loc == 0:
raw = struct.unpack('>%dH' % n, self.data[off:off + 2 * n])
self.loca = [v * 2 for v in raw]
else:
self.loca = list(struct.unpack('>%dI' % n, self.data[off:off + 4 * n]))
def _read_cmap(self):
off, _ = self.tables['cmap']
n = struct.unpack('>H', self.data[off + 2:off + 4])[0]
best = None
for i in range(n):
rec = off + 4 + i * 8
pid, eid, sub = struct.unpack('>HHI', self.data[rec:rec + 8])
fmt = struct.unpack('>H', self.data[off + sub:off + sub + 2])[0]
if fmt in (4, 12):
if best is None or fmt == 12:
best = (fmt, off + sub)
fmt, sub = best
self.cmap = {}
if fmt == 4:
seg_x2 = struct.unpack('>H', self.data[sub + 6:sub + 8])[0]
seg = seg_x2 // 2
base = sub + 14
ends = struct.unpack('>%dH' % seg, self.data[base:base + seg_x2])
base += seg_x2 + 2
starts = struct.unpack('>%dH' % seg, self.data[base:base + seg_x2])
base += seg_x2
deltas = struct.unpack('>%dh' % seg, self.data[base:base + seg_x2])
range_off_pos = base + seg_x2
offsets = struct.unpack(
'>%dH' % seg, self.data[range_off_pos:range_off_pos + seg_x2])
for i in range(seg):
for c in range(starts[i], min(ends[i], 0xFFFF) + 1):
if offsets[i] == 0:
gid = (c + deltas[i]) & 0xFFFF
else:
p = range_off_pos + i * 2 + offsets[i] + (c - starts[i]) * 2
gid = struct.unpack('>H', self.data[p:p + 2])[0]
if gid:
gid = (gid + deltas[i]) & 0xFFFF
if gid:
self.cmap[c] = gid
else:
n_groups = struct.unpack('>I', self.data[sub + 12:sub + 16])[0]
for i in range(n_groups):
p = sub + 16 + i * 12
s, e, g = struct.unpack('>III', self.data[p:p + 12])
for c in range(s, e + 1):
self.cmap[c] = g + (c - s)
def contours(self, codepoint):
gid = self.cmap[codepoint]
return self._glyph_contours(gid)
def _glyph_contours(self, gid, depth=0):
goff, _ = self.tables['glyf']
start, end = self.loca[gid], self.loca[gid + 1]
if start == end:
return []
d = self.data[goff + start:goff + end]
n_contours = struct.unpack('>h', d[0:2])[0]
if n_contours < 0:
return self._composite(d, depth)
end_pts = struct.unpack('>%dH' % n_contours, d[10:10 + 2 * n_contours])
n_points = end_pts[-1] + 1
p = 10 + 2 * n_contours
instr_len = struct.unpack('>H', d[p:p + 2])[0]
p += 2 + instr_len
flags = []
while len(flags) < n_points:
f = d[p]
p += 1
flags.append(f)
if f & 8:
rep = d[p]
p += 1
flags.extend([f] * rep)
flags = flags[:n_points]
xs, x = [], 0
for f in flags:
if f & 2:
dx = d[p]
p += 1
x += dx if f & 16 else -dx
elif not f & 16:
dx = struct.unpack('>h', d[p:p + 2])[0]
p += 2
x += dx
xs.append(x)
ys, y = [], 0
for f in flags:
if f & 4:
dy = d[p]
p += 1
y += dy if f & 32 else -dy
elif not f & 32:
dy = struct.unpack('>h', d[p:p + 2])[0]
p += 2
y += dy
ys.append(y)
out, first = [], 0
for e in end_pts:
pts = [(xs[i], ys[i], bool(flags[i] & 1)) for i in range(first, e + 1)]
if pts:
out.append(pts)
first = e + 1
return out
def _composite(self, d, depth):
if depth > 4:
return []
out = []
p = 10
while True:
flags, glyph_index = struct.unpack('>HH', d[p:p + 4])
p += 4
if flags & 1:
a1, a2 = struct.unpack('>hh', d[p:p + 4])
p += 4
else:
a1, a2 = struct.unpack('>bb', d[p:p + 2])
p += 2
sx = sy = 1.0
s01 = s10 = 0.0
if flags & 8:
sx = sy = _f2dot14(d, p)
p += 2
elif flags & 0x40:
sx = _f2dot14(d, p)
sy = _f2dot14(d, p + 2)
p += 4
elif flags & 0x80:
sx = _f2dot14(d, p)
s01 = _f2dot14(d, p + 2)
s10 = _f2dot14(d, p + 4)
sy = _f2dot14(d, p + 6)
p += 8
dx, dy = (a1, a2) if flags & 2 else (0, 0)
for contour in self._glyph_contours(glyph_index, depth + 1):
out.append([
(x * sx + y * s10 + dx, x * s01 + y * sy + dy, on)
for x, y, on in contour
])
if not flags & 0x20:
break
return out
def _f2dot14(d, p):
return struct.unpack('>h', d[p:p + 2])[0] / 16384.0
def to_cubic(contour):
"""TrueType quadratic contour -> list of cubic segments [(p0,c1,c2,p1), ...]."""
pts = []
for x, y, on in contour:
pts.append((float(x), float(y), on))
if not pts[0][2]:
if pts[-1][2]:
pts = [pts[-1]] + pts[:-1]
else:
mx = (pts[0][0] + pts[-1][0]) / 2
my = (pts[0][1] + pts[-1][1]) / 2
pts = [(mx, my, True)] + pts
expanded = []
for i, (x, y, on) in enumerate(pts):
nx, ny, non = pts[(i + 1) % len(pts)]
expanded.append((x, y, on))
if not on and not non:
expanded.append(((x + nx) / 2, (y + ny) / 2, True))
segments = []
i = 0
n = len(expanded)
while i < n:
x0, y0, on0 = expanded[i]
assert on0
x1, y1, on1 = expanded[(i + 1) % n]
if on1:
segments.append(((x0, y0), (x0, y0), (x1, y1), (x1, y1)))
i += 1
else:
x2, y2, _ = expanded[(i + 2) % n]
c1 = (x0 + 2 / 3 * (x1 - x0), y0 + 2 / 3 * (y1 - y0))
c2 = (x2 + 2 / 3 * (x1 - x2), y2 + 2 / 3 * (y1 - y2))
segments.append(((x0, y0), c1, c2, (x2, y2)))
i += 2
return segments
def _find_font():
root = os.path.expanduser('~/.pub-cache/hosted/pub.dev')
candidates = sorted(
name for name in os.listdir(root)
if name.startswith('material_symbols_icons-')
)
if not candidates:
raise SystemExit('material_symbols_icons не найден в pub-cache')
return os.path.join(root, candidates[-1], 'lib', 'fonts',
'MaterialSymbolsOutlined.ttf')
FONT = os.environ.get('MATERIAL_SYMBOLS_TTF') or _find_font()
UPM = 960.0
CANVAS = 600.0
MIN_AREA = 500.0
_font = Font(FONT)
def _bezier(seg, t):
(x0, y0), (x1, y1), (x2, y2), (x3, y3) = seg
mt = 1 - t
x = mt ** 3 * x0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t ** 3 * x3
y = mt ** 3 * y0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t ** 3 * y3
return x, y
def _split_cubic(seg, t):
p0, c1, c2, p3 = seg
def mid(a, b, k):
return (a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k)
a = mid(p0, c1, t)
b = mid(c1, c2, t)
c = mid(c2, p3, t)
d = mid(a, b, t)
e = mid(b, c, t)
f = mid(d, e, t)
return (p0, a, d, f), (f, e, c, p3)
def _seg_metrics(seg, steps=64):
pts = [_bezier(seg, i / steps) for i in range(steps + 1)]
acc = [0.0]
total = 0.0
for i in range(steps):
total += math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1])
acc.append(total)
return acc, total, steps
def _t_at_length(metrics, target):
acc, total, steps = metrics
if total <= 0:
return 0.0
for i in range(steps):
if acc[i + 1] >= target:
span = acc[i + 1] - acc[i]
k = 0.0 if span <= 0 else (target - acc[i]) / span
return (i + k) / steps
return 1.0
def _canvas_segments(contour):
out = []
for seg in to_cubic(contour):
out.append(tuple(
(x / UPM * CANVAS, (1 - y / UPM) * CANVAS) for x, y in seg
))
return out
def _exact_path(contour, count):
"""Subdivide the original beziers: geometry stays bit-for-bit the glyph."""
segments = _canvas_segments(contour)
metrics = [_seg_metrics(s) for s in segments]
lengths = [m[1] for m in metrics]
total = sum(lengths)
if total <= 0:
return []
quota = [max(1, int(round(count * length / total))) for length in lengths]
while sum(quota) > count and max(quota) > 1:
idx = max(range(len(quota)), key=lambda i: (quota[i], lengths[i]))
quota[idx] -= 1
while sum(quota) < count:
idx = max(range(len(quota)), key=lambda i: lengths[i] / quota[i])
quota[idx] += 1
pieces = []
for seg, metric, parts in zip(segments, metrics, quota):
rest = seg
consumed = 0.0
length = metric[1]
for k in range(parts - 1):
t_abs = _t_at_length(metric, length * (k + 1) / parts)
span = 1.0 - consumed
t_local = 0.0 if span <= 0 else (t_abs - consumed) / span
t_local = min(max(t_local, 1e-4), 1 - 1e-4)
head, rest = _split_cubic(rest, t_local)
pieces.append(head)
consumed = t_abs
pieces.append(rest)
path = []
n = len(pieces)
for i, (p0, c1, _, _) in enumerate(pieces):
prev_c2 = pieces[(i - 1) % n][2]
path.append((
p0,
(prev_c2[0] - p0[0], prev_c2[1] - p0[1]),
(c1[0] - p0[0], c1[1] - p0[1]),
))
return path
def _area(path):
area = 0.0
n = len(path)
for i in range(n):
x0, y0 = path[i][0]
x1, y1 = path[(i + 1) % n][0]
area += x0 * y1 - x1 * y0
return area / 2
def glyph_paths(codepoint, count):
out = []
for contour in _font.contours(codepoint):
path = _exact_path(contour, count)
if not path:
continue
area = _area(path)
if abs(area) < MIN_AREA:
continue
out.append((area, path))
return out
def _centroid(path):
return (sum(p[0][0] for p in path) / len(path),
sum(p[0][1] for p in path) / len(path))
def _collapsed(path):
cx, cy = _centroid(path)
return [((cx, cy), (0.0, 0.0), (0.0, 0.0))] * len(path)
def _rotate(path, shift):
return path[shift:] + path[:shift]
def _align(src, dst):
n = len(src)
best, best_cost = 0, None
for shift in range(n):
cost = 0.0
for i in range(n):
x0, y0 = src[i][0]
x1, y1 = dst[(i + shift) % n][0]
cost += (x0 - x1) ** 2 + (y0 - y1) ** 2
if best_cost is None or cost < best_cost:
best, best_cost = shift, cost
return _rotate(dst, best)
def outer_sign(shapes):
"""The biggest contour is always an outline: its winding defines 'outer'."""
biggest = max(shapes, key=lambda s: abs(s[0]))
return 1.0 if biggest[0] > 0 else -1.0
def pair_glyphs(from_cp, to_cp, count):
"""[(path_from, path_to), ...] with matching vertex counts and winding."""
src = glyph_paths(from_cp, count)
dst = glyph_paths(to_cp, count)
src_sign = outer_sign(src)
dst_sign = outer_sign(dst)
pairs = []
for outer in (True, False):
a = sorted([s for s in src if (s[0] * src_sign > 0) == outer],
key=lambda s: -abs(s[0]))
b = sorted([s for s in dst if (s[0] * dst_sign > 0) == outer],
key=lambda s: -abs(s[0]))
for i in range(max(len(a), len(b))):
if i < len(a) and i < len(b):
pairs.append((a[i][1], _align(a[i][1], b[i][1])))
elif i < len(a):
pairs.append((a[i][1], _collapsed(a[i][1])))
else:
pairs.append((_collapsed(b[i][1]), b[i][1]))
return pairs
MIC = 0xE31D
CAM = 0xE04B
SEND = 0xE163
POINTS = 56
FPS = 60
DUR = 24
OUT_DIR = os.path.join(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))), 'assets', 'lottie')
EASE_OUT = {'x': 0.2, 'y': 0}
EASE_IN = {'x': 0.0, 'y': 1.0}
EASE_OUT_V = {'x': [0.2], 'y': [0]}
EASE_IN_V = {'x': [0.0], 'y': [1.0]}
EASE_SOFT_OUT_V = {'x': [0.33], 'y': [0]}
EASE_SOFT_IN_V = {'x': [0.25], 'y': [1.0]}
def r2(value):
return round(value, 2)
def path_value(path):
return {
'i': [[r2(p[1][0]), r2(p[1][1])] for p in path],
'o': [[r2(p[2][0]), r2(p[2][1])] for p in path],
'v': [[r2(p[0][0]), r2(p[0][1])] for p in path],
'c': True,
}
COLLAPSE_END = 10
GROW_START = 12
def _is_point(path):
first = path[0][0]
return all(abs(p[0][0] - first[0]) < 0.01 and abs(p[0][1] - first[1]) < 0.01
for p in path)
def shape_item(index, path_from, path_to):
start, end = 0, DUR
if _is_point(path_to):
end = COLLAPSE_END
elif _is_point(path_from):
start = GROW_START
return {
'ind': index,
'ty': 'sh',
'ix': index + 1,
'ks': {
'a': 1,
'k': [
{'i': EASE_IN, 'o': EASE_OUT, 't': start,
's': [path_value(path_from)]},
{'t': end, 's': [path_value(path_to)]},
],
'ix': 2,
},
'nm': 'Path %d' % (index + 1),
'mn': 'ADBE Vector Shape - Group',
'hd': False,
}
def keyframes(stops, vector):
out = []
for i, (frame, value) in enumerate(stops):
entry = {'t': frame, 's': value if isinstance(value, list) else [value]}
if i < len(stops) - 1:
if vector:
entry['i'] = EASE_IN_V if i == 0 else EASE_SOFT_IN_V
entry['o'] = EASE_OUT_V if i == 0 else EASE_SOFT_OUT_V
else:
entry['i'] = EASE_IN
entry['o'] = EASE_OUT
out.append(entry)
return out
def transform(rotation=None, scale=None, offset_x=None):
half = CANVAS / 2
ks = {
'o': {'a': 0, 'k': 100, 'ix': 11},
'r': {'a': 0, 'k': 0, 'ix': 10},
'p': {'a': 0, 'k': [half, half, 0], 'ix': 2},
'a': {'a': 0, 'k': [half, half, 0], 'ix': 1},
's': {'a': 0, 'k': [100, 100, 100], 'ix': 6},
}
if rotation:
ks['r'] = {'a': 1, 'k': keyframes(rotation, vector=False), 'ix': 10}
if scale:
stops = [(f, [v, v, 100]) for f, v in scale]
ks['s'] = {'a': 1, 'k': keyframes(stops, vector=True), 'ix': 6}
if offset_x:
stops = [(f, [half + dx, half, 0]) for f, dx in offset_x]
ks['p'] = {'a': 1, 'k': keyframes(stops, vector=True), 'ix': 2}
return ks
def build(name, from_cp, to_cp, rotation=None, scale=None, offset_x=None):
pairs = pair_glyphs(from_cp, to_cp, POINTS)
items = [shape_item(i, a, b) for i, (a, b) in enumerate(pairs)]
items.append({
'ty': 'fl',
'c': {'a': 0, 'k': [1, 1, 1, 1], 'ix': 4},
'o': {'a': 0, 'k': 100, 'ix': 5},
'r': 1,
'bm': 0,
'nm': 'Fill',
'mn': 'ADBE Vector Graphic - Fill',
'hd': False,
})
items.append({
'ty': 'tr',
'p': {'a': 0, 'k': [0, 0], 'ix': 2},
'a': {'a': 0, 'k': [0, 0], 'ix': 1},
's': {'a': 0, 'k': [100, 100], 'ix': 3},
'r': {'a': 0, 'k': 0, 'ix': 6},
'o': {'a': 0, 'k': 100, 'ix': 7},
'sk': {'a': 0, 'k': 0, 'ix': 4},
'sa': {'a': 0, 'k': 0, 'ix': 5},
'nm': 'Transform',
})
return {
'v': '5.12.1',
'fr': FPS,
'ip': 0,
'op': DUR,
'w': int(CANVAS),
'h': int(CANVAS),
'nm': name,
'ddd': 0,
'assets': [],
'layers': [{
'ddd': 0,
'ind': 1,
'ty': 4,
'nm': name,
'sr': 1,
'ks': transform(rotation, scale, offset_x),
'ao': 0,
'shapes': [{
'ty': 'gr',
'it': items,
'nm': 'Group 1',
'np': len(items),
'cix': 2,
'bm': 0,
'ix': 1,
'mn': 'ADBE Vector Group',
'hd': False,
}],
'ip': 0,
'op': DUR,
'st': 0,
'bm': 0,
}],
'markers': [],
}
SPECS = [
dict(
name='ic_mic_to_videocam',
from_cp=MIC, to_cp=CAM,
rotation=[(0, 0), (10, -14), (DUR, 0)],
scale=[(0, 100), (10, 88), (DUR, 100)],
),
dict(
name='ic_videocam_to_mic',
from_cp=CAM, to_cp=MIC,
rotation=[(0, 0), (11, 14), (DUR, 0)],
scale=[(0, 100), (11, 111), (DUR, 100)],
),
dict(
name='ic_mic_to_send',
from_cp=MIC, to_cp=SEND,
scale=[(0, 100), (9, 90), (DUR, 100)],
offset_x=[(0, 0), (9, -34), (19, 12), (DUR, 0)],
),
dict(
name='ic_videocam_to_send',
from_cp=CAM, to_cp=SEND,
rotation=[(0, 0), (9, 10), (DUR, 0)],
scale=[(0, 100), (9, 92), (DUR, 100)],
offset_x=[(0, 0), (9, -26), (19, 10), (DUR, 0)],
),
dict(
name='ic_send_to_mic',
from_cp=SEND, to_cp=MIC,
rotation=[(0, 0), (10, 9), (DUR, 0)],
scale=[(0, 100), (10, 91), (DUR, 100)],
offset_x=[(0, 0), (10, 30), (19, -10), (DUR, 0)],
),
dict(
name='ic_send_to_videocam',
from_cp=SEND, to_cp=CAM,
rotation=[(0, 0), (10, -11), (DUR, 0)],
scale=[(0, 100), (10, 90), (DUR, 100)],
offset_x=[(0, 0), (10, 24), (19, -8), (DUR, 0)],
),
]
def main():
os.makedirs(OUT_DIR, exist_ok=True)
for spec in SPECS:
data = build(**spec)
path = os.path.join(OUT_DIR, spec['name'] + '.json')
with open(path, 'w') as fh:
json.dump(data, fh, separators=(',', ':'))
print(f"{spec['name']:24s} {os.path.getsize(path) // 1024:3d} KB "
f"paths={len(data['layers'][0]['shapes'][0]['it']) - 2}")
if __name__ == '__main__':
main()