Skip to content

Instantly share code, notes, and snippets.

@gelin
Last active September 14, 2024 05:20
Show Gist options
  • Save gelin/5297ba7bdc2011b765a8d9f082939669 to your computer and use it in GitHub Desktop.
Save gelin/5297ba7bdc2011b765a8d9f082939669 to your computer and use it in GitHub Desktop.
Converts Flipper Animation assets to video files
#!/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)
@gelin
Copy link
Author

gelin commented Sep 14, 2024

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment