Last active
September 14, 2024 05:20
-
-
Save gelin/5297ba7bdc2011b765a8d9f082939669 to your computer and use it in GitHub Desktop.
Converts Flipper Animation assets to video files
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# Converts Flipper Animation assets to video files. | |
# Typical usage: ./dolphin-animation.py ${FLIPPERZERO_FIRMWARE}/assets/dolphin/external/L2_Wake_up_128x64 | |
# Dependencies: python3-pil, ffmpeg | |
# Font: https://www.fontsaddict.com/font/download/haxrcorp-4089-cyrillic-altgr-regular.ttf | |
import argparse | |
import enum | |
import os | |
import re | |
import subprocess | |
import tempfile | |
from dataclasses import dataclass | |
from PIL import Image, ImageDraw, ImageFont | |
AMBER = (254, 138, 44) | |
BLACK = (0, 0, 0) | |
BORDER_SIZE = 9 | |
TEXT_PADDING = (1, 2, 4, 4) # top, right, bottom, left | |
TEXT_SPACING = 1 | |
TIP_SIZE = 5 | |
FULL_HEIGHT = 64 | |
@dataclass | |
class BubbleCornerData: | |
pixels: tuple | |
width: int | |
height: int | |
center: int | |
CORNER1 = BubbleCornerData( | |
pixels=( | |
(AMBER, BLACK, None, None, None), | |
(AMBER, AMBER, BLACK, None, None), | |
(AMBER, AMBER, AMBER, BLACK, None), | |
(AMBER, AMBER, AMBER, AMBER, BLACK), | |
(AMBER, AMBER, AMBER, BLACK, None), | |
(AMBER, AMBER, BLACK, None, None), | |
(AMBER, BLACK, None, None, None) | |
), | |
width=5, | |
height=7, | |
center=3 | |
) | |
CORNER2 = BubbleCornerData( | |
pixels=( | |
(AMBER, BLACK, None, None, None), | |
(AMBER, AMBER, BLACK, None, None), | |
(AMBER, AMBER, AMBER, BLACK, None), | |
(BLACK, BLACK, BLACK, BLACK, BLACK), | |
), | |
width=5, | |
height=4, | |
center=3 | |
) | |
@dataclass | |
class Meta: | |
name: str = None | |
data_path: str = None | |
width: int = 128 | |
height: int = 64 | |
passive_frames: int = 0 | |
active_frames: int = 0 | |
frames_order: [int] = None | |
active_cycles: int = 0 | |
frame_rate: int = 2 | |
bubble_slots: int = 0 | |
all_frames: [int] = None | |
@dataclass | |
class Bubble: | |
slot: int = 0 | |
x: int = 0 | |
y: int = 0 | |
text: str = None | |
align_h: str = None | |
align_v: str = None | |
start_frame: int = 0 | |
end_frame: int = 0 | |
class BubbleCorner: | |
def __init__(self, bubble, xy, wh): | |
self.data = CORNER1 | |
self.dx = 1 | |
self.dy = 1 | |
self.trans = False | |
self.skip_draw = False | |
if bubble.align_v == 'top' and bubble.align_h == 'center': | |
self.trans = True | |
self.dy = -1 | |
self.x = xy[0] + wh[0] // 2 - self.data.center | |
self.y = xy[1] | |
elif bubble.align_h == 'right' and bubble.align_v == 'top': | |
self.data = CORNER2 | |
self.dy = -1 | |
self.x = xy[0] + wh[0] | |
self.y = xy[1] + self.data.center | |
elif bubble.align_h == 'right' and bubble.align_v == 'center': | |
self.x = xy[0] + wh[0] | |
self.y = xy[1] + wh[1] // 2 - self.data.center | |
elif bubble.align_h == 'right' and bubble.align_v == 'bottom': | |
self.data = CORNER2 | |
self.x = xy[0] + wh[0] | |
self.y = xy[1] + wh[1] - self.data.center | |
elif bubble.align_h == 'left' and bubble.align_v == 'top': | |
self.data = CORNER2 | |
self.dx = -1 | |
self.dy = -1 | |
self.x = xy[0] | |
self.y = xy[1] + self.data.center | |
elif bubble.align_h == 'left' and bubble.align_v == 'center': | |
self.dx = -1 | |
self.x = xy[0] | |
self.y = xy[1] + wh[1] // 2 - self.data.center | |
elif bubble.align_h == 'left' and bubble.align_v == 'bottom': | |
self.data = CORNER2 | |
self.dx = -1 | |
self.x = xy[0] | |
self.y = xy[1] + wh[1] - self.data.center | |
else: | |
self.skip_draw = True | |
def draw(self, image): | |
if self.skip_draw: | |
return | |
if self.trans: | |
for cx in range(self.data.height): | |
for cy in range(self.data.width): | |
# print(self.x + cx * self.dx, self.y + cy * self.dy, self.data.pixels[cx][cy]) | |
if self.data.pixels[cx][cy] is not None: | |
image.putpixel((self.x + cx * self.dx, self.y + cy * self.dy), self.data.pixels[cx][cy]) | |
else: | |
for cx in range(self.data.width): | |
for cy in range(self.data.height): | |
# print(self.x + cx * self.dx, self.y + cy * self.dy, self.data.pixels[cy][cx]) | |
if self.data.pixels[cy][cx] is not None: | |
image.putpixel((self.x + cx * self.dx, self.y + cy * self.dy), self.data.pixels[cy][cx]) | |
frames = {} | |
meta = Meta() | |
bubbles = {} | |
font = None | |
def load_font(args): | |
global font | |
print('Using font', args.font, 'of size', args.font_size) | |
try: | |
font = ImageFont.truetype(args.font, args.font_size) | |
except Exception as e: | |
print('Failed to load font:', e) | |
print('Using default font') | |
font = ImageFont.load_default() | |
def load_meta(path): | |
meta.data_path = path | |
bubbles_list = [] | |
bubble = Bubble() | |
with open(os.path.join(path, 'meta.txt'), 'r') as file: | |
for line in file.readlines(): | |
m = re.match(r'(.*)\s*:\s*(.*)', line) | |
if m: | |
(key, value) = m.groups() | |
if key == 'Filetype': | |
if value != 'Flipper Animation': | |
raise Exception('Unknown filetype: %s' % value) | |
elif key == 'Width': | |
meta.width = int(value) | |
elif key == 'Height': | |
meta.height = int(value) | |
elif key == 'Passive frames': | |
meta.passive_frames = int(value) | |
elif key == 'Active frames': | |
meta.active_frames = int(value) | |
elif key == 'Frames order': | |
meta.frames_order = [int(i) for i in value.split()] | |
elif key == 'Active cycles': | |
meta.active_cycles = int(value) | |
elif key == 'Frame rate': | |
meta.frame_rate = int(value) | |
elif key == 'Bubble slots': | |
meta.bubble_slots = int(value) | |
elif key == 'Slot': | |
bubble = Bubble() | |
bubble.slot = int(value) | |
bubbles_list.append(bubble) | |
elif key == 'X': | |
bubble.x = int(value) | |
elif key == 'Y': | |
bubble.y = int(value) - (FULL_HEIGHT - meta.height) | |
# the Y is defined in screen coords, but smaller images are aligned on the screen bottom | |
elif key == 'Text': | |
bubble.text = value.replace('\\n', '\n') | |
elif key == 'AlignH': | |
bubble.align_h = value.lower() | |
elif key == 'AlignV': | |
bubble.align_v = value.lower() | |
elif key == 'StartFrame': | |
bubble.start_frame = int(value) | |
elif key == 'EndFrame': | |
bubble.end_frame = int(value) | |
meta.all_frames = meta.frames_order[0:meta.passive_frames] + ( | |
meta.frames_order[meta.passive_frames:] * meta.active_cycles) | |
if meta.bubble_slots > 0: | |
frames_per_bubble = len(meta.all_frames) | |
meta.all_frames = meta.all_frames * meta.bubble_slots | |
for b in bubbles_list: | |
for f in range(b.start_frame, b.end_frame + 1): | |
bubbles[f + b.slot * frames_per_bubble] = b | |
meta.name = re.search(r'(\S+)_[\dx]+$', os.path.basename(path)).group(1) | |
class Format(enum.Enum): | |
webp = 'webp', ['-c:v', 'libwebp', '-lossless', '1', '-loop', '0'] | |
gif = 'gif', ['-filter_complex', 'split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse=dither=none', '-loop', '0'] | |
mp4 = 'mp4', ['-c:v', 'libx264', '-crf', '0'] | |
# https://stackoverflow.com/a/54732120/3438640 | |
def __new__(cls, *args, **kwds): | |
obj = object.__new__(cls) | |
obj._value_ = args[0] | |
return obj | |
def __init__(self, _: str, ffmpeg_args: list[str] = None): | |
self._ffmpeg_args_ = ffmpeg_args | |
def __str__(self): | |
return self.value | |
@property | |
def ffmpeg_args(self): | |
return self._ffmpeg_args_ | |
@staticmethod | |
def argparse(s: str): | |
try: | |
return Format[s] | |
except KeyError: | |
return s | |
def filename(self, name: str): | |
return '%s.%s' % (name, self) | |
def write_video(output_format: Format): | |
with tempfile.TemporaryDirectory(prefix='dolphin') as tmpdir: | |
# tmpdir = tempfile.mkdtemp(prefix='dolphin') | |
for i, f in enumerate(meta.all_frames): | |
frame = load_frame(meta.data_path, f) | |
frame = add_bubble(frame, i) | |
frame.save(os.path.join(tmpdir, '%s_%03d.png' % (meta.name, i))) | |
subprocess.run(['ffmpeg', '-hide_banner', | |
'-framerate', str(meta.frame_rate), '-pattern_type', 'glob', | |
'-i', os.path.join(tmpdir, '%s_*.png' % meta.name) | |
] + output_format.ffmpeg_args + [ | |
'-y', output_format.filename(meta.name)]) | |
def load_frame(path, frame_index): | |
if frame_index in frames: | |
return frames[frame_index] | |
frame = reframe_image(os.path.join(path, 'frame_%d.png' % frame_index)) | |
frames[frame_index] = frame | |
return frame | |
def reframe_image(path): | |
width = meta.width + BORDER_SIZE * 2 | |
height = meta.height + BORDER_SIZE * 2 | |
orig_image = Image.open(path) | |
orig_image = orig_image.convert('RGB') | |
new_image = Image.new('RGB', (width, height), color=BLACK) | |
# draw border | |
# border: 250x140 px, 1px amber, 9px border radius, 3px black, 5px border radius, 6px amber | |
draw = ImageDraw.Draw(new_image) | |
draw.rounded_rectangle(((0, 0), (width - 1, height - 1)), radius=11, fill=AMBER) | |
draw.rounded_rectangle(((2, 2), (width - 3, height - 3)), radius=9, width=2, outline=BLACK) | |
# copy image | |
new_image.paste(orig_image, (BORDER_SIZE, BORDER_SIZE)) | |
# change pixels color | |
for y in range(orig_image.height): | |
for x in range(orig_image.width): | |
r, g, b = orig_image.getpixel((x, y)) | |
# white → amber | |
if r > 127 and g > 127 and b > 127: | |
new_image.putpixel((x + BORDER_SIZE, y + BORDER_SIZE), AMBER) | |
return new_image | |
def add_bubble(image, index): | |
if index not in bubbles: | |
return image | |
image = image.copy() | |
bubble = bubbles[index] | |
# print(bubble) | |
draw = ImageDraw.Draw(image) | |
wh = draw.multiline_textsize(bubble.text, font=font, spacing=TEXT_SPACING) | |
wh = (wh[0] + TEXT_PADDING[1] + TEXT_PADDING[3], wh[1] + TEXT_PADDING[0] + TEXT_PADDING[2]) | |
xy = (bubble.x + BORDER_SIZE, bubble.y + BORDER_SIZE) | |
draw.rounded_rectangle((xy, (xy[0] + wh[0], xy[1] + wh[1])), radius=1, width=1, fill=AMBER, outline=BLACK) | |
draw.multiline_text((xy[0] + TEXT_PADDING[3], xy[1] + TEXT_PADDING[0]), bubble.text, font=font, | |
spacing=TEXT_SPACING, fill=BLACK) | |
draw_bubble_corner(image, bubble, xy, wh) | |
return image | |
def draw_bubble_corner(image, bubble, xy, wh): | |
corner = BubbleCorner(bubble, xy, wh) | |
corner.draw(image) | |
if __name__ == '__main__': | |
parser = argparse.ArgumentParser(description='Make video from Flipper dolphin animations') | |
parser.add_argument('path', metavar='PATH', help='Path to animation folder') | |
parser.add_argument('--format', help='Output format: [webp], gif or mp4', type=Format.argparse, default=Format.webp) | |
parser.add_argument('-f', '--font', help='Font file to use [haxrcorp_4089_cyrillic_altgr.ttf]', | |
default=os.path.join(os.path.dirname(os.path.realpath(__file__)), | |
'haxrcorp_4089_cyrillic_altgr.ttf')) | |
parser.add_argument('--font-size', help='Font size to use [16]', type=int, default=16) | |
args = parser.parse_args() | |
# print(args) | |
load_font(args) | |
data_path = args.path | |
print('Reading from', data_path) | |
load_meta(data_path) | |
# print(meta) | |
# print(bubbles) | |
output_format = args.format | |
print('Writing to', output_format.filename(meta.name)) | |
write_video(args.format) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
https://gelin.ru/flipper/dolphin-animations/