mirror of
https://github.com/fulpstation/fulpstation.git
synced 2025-12-10 01:57:01 +00:00
It hooks into git as a merge driver and automatically runs with merges. It prints a log of what it did, and if any specific states are conflicted it indicates them and does not mark the merge as successful. The conflicting icon can then be opened in DreamMaker and the conflicting states resolved there.
254 lines
7.7 KiB
Python
254 lines
7.7 KiB
Python
# Tools for working with modern DreamMaker icon files (PNGs + metadata)
|
|
|
|
import math
|
|
from PIL import Image
|
|
from PIL.PngImagePlugin import PngInfo
|
|
|
|
DEFAULT_SIZE = 32, 32
|
|
LOOP_UNLIMITED = 0
|
|
LOOP_ONCE = 1
|
|
|
|
NORTH = 1
|
|
SOUTH = 2
|
|
EAST = 4
|
|
WEST = 8
|
|
SOUTHEAST = SOUTH|EAST
|
|
SOUTHWEST = SOUTH|WEST
|
|
NORTHEAST = NORTH|EAST
|
|
NORTHWEST = NORTH|WEST
|
|
|
|
CARDINALS = [NORTH, SOUTH, EAST, WEST]
|
|
DIR_ORDER = [SOUTH, NORTH, EAST, WEST, SOUTHEAST, SOUTHWEST, NORTHEAST, NORTHWEST]
|
|
DIR_NAMES = {
|
|
'SOUTH': SOUTH,
|
|
'NORTH': NORTH,
|
|
'EAST': EAST,
|
|
'WEST': WEST,
|
|
'SOUTHEAST': SOUTHEAST,
|
|
'SOUTHWEST': SOUTHWEST,
|
|
'NORTHEAST': NORTHEAST,
|
|
'NORTHWEST': NORTHWEST,
|
|
**{str(x): x for x in DIR_ORDER},
|
|
**{x: x for x in DIR_ORDER},
|
|
'0': SOUTH,
|
|
None: SOUTH,
|
|
}
|
|
|
|
class Dmi:
|
|
version = "4.0"
|
|
|
|
def __init__(self, width, height):
|
|
self.width = width
|
|
self.height = height
|
|
self.states = []
|
|
|
|
@classmethod
|
|
def from_file(cls, fname):
|
|
image = Image.open(fname)
|
|
|
|
# no metadata = regular image file
|
|
if 'Description' not in image.info:
|
|
dmi = Dmi(*image.size)
|
|
state = dmi.state("")
|
|
state.frame(image)
|
|
return dmi
|
|
|
|
# read metadata
|
|
metadata = image.info['Description']
|
|
line_iter = iter(metadata.splitlines())
|
|
assert next(line_iter) == "# BEGIN DMI"
|
|
assert next(line_iter) == f"version = {cls.version}"
|
|
|
|
dmi = Dmi(*DEFAULT_SIZE)
|
|
state = None
|
|
|
|
for line in line_iter:
|
|
if line == "# END DMI":
|
|
break
|
|
key, value = line.lstrip().split(" = ")
|
|
if key == 'width':
|
|
dmi.width = int(value)
|
|
elif key == 'height':
|
|
dmi.height = int(value)
|
|
elif key == 'state':
|
|
state = dmi.state(unescape(value))
|
|
elif key == 'dirs':
|
|
state.dirs = int(value)
|
|
elif key == 'frames':
|
|
state._nframes = int(value)
|
|
elif key == 'delay':
|
|
state.delays = [parse_num(x) for x in value.split(',')]
|
|
elif key == 'loop':
|
|
state.loop = int(value)
|
|
elif key == 'rewind':
|
|
state.rewind = parse_bool(value)
|
|
elif key == 'hotspot':
|
|
x, y, frm = [int(x) for x in value.split(',')]
|
|
state.hotspot(frm - 1, x, y)
|
|
elif key == 'movement':
|
|
state.movement = parse_bool(value)
|
|
else:
|
|
raise NotImplementedError(key)
|
|
|
|
# cut image into frames
|
|
width, height = image.size
|
|
gridwidth = width // dmi.width
|
|
i = 0
|
|
for state in dmi.states:
|
|
for frame in range(state._nframes):
|
|
for dir in range(state.dirs):
|
|
px = dmi.width * (i % gridwidth)
|
|
py = dmi.height * (i // gridwidth)
|
|
im = image.crop((px, py, px + dmi.width, py + dmi.height))
|
|
assert im.size == (dmi.width, dmi.height)
|
|
state.frames.append(im)
|
|
i += 1
|
|
state._nframes = None
|
|
|
|
return dmi
|
|
|
|
def state(self, *args, **kwargs):
|
|
s = State(self, *args, **kwargs)
|
|
self.states.append(s)
|
|
return s
|
|
|
|
@property
|
|
def default_state(self):
|
|
return self.states[0]
|
|
|
|
def get_state(self, name):
|
|
for state in self.states:
|
|
if state.name == name:
|
|
return state
|
|
raise KeyError(name)
|
|
return self.default_state
|
|
|
|
def _assemble_comment(self):
|
|
comment = "# BEGIN DMI\n"
|
|
comment += f"version = {self.version}\n"
|
|
comment += f"\twidth = {self.width}\n"
|
|
comment += f"\theight = {self.height}\n"
|
|
for state in self.states:
|
|
comment += f"state = {escape(state.name)}\n"
|
|
comment += f"\tdirs = {state.dirs}\n"
|
|
comment += f"\tframes = {state.framecount}\n"
|
|
if state.framecount > 1 and len(state.delays): #any(x != 1 for x in state.delays):
|
|
comment += "\tdelay = " + ",".join(map(str, state.delays)) + "\n"
|
|
if state.loop != 0:
|
|
comment += f"\tloop = {state.loop}\n"
|
|
if state.rewind:
|
|
comment += "\trewind = 1\n"
|
|
if state.movement:
|
|
comment += "\tmovement = 1\n"
|
|
if state.hotspots and any(state.hotspots):
|
|
current = None
|
|
for i, value in enumerate(state.hotspots):
|
|
if value != current:
|
|
x, y = value
|
|
comment += f"\thotspot = {x},{y},{i + 1}\n"
|
|
current = value
|
|
comment += "# END DMI"
|
|
return comment
|
|
|
|
def to_file(self, filename, *, palette=False):
|
|
# assemble comment
|
|
comment = self._assemble_comment()
|
|
|
|
# assemble spritesheet
|
|
W, H = self.width, self.height
|
|
num_frames = sum(len(state.frames) for state in self.states)
|
|
sqrt = math.ceil(math.sqrt(num_frames))
|
|
output = Image.new('RGBA', (sqrt * W, math.ceil(num_frames / sqrt) * H))
|
|
|
|
i = 0
|
|
for state in self.states:
|
|
for frame in state.frames:
|
|
output.paste(frame, ((i % sqrt) * W, (i // sqrt) * H))
|
|
i += 1
|
|
|
|
# save
|
|
pnginfo = PngInfo()
|
|
pnginfo.add_text('Description', comment, zip=True)
|
|
if palette:
|
|
output = output.convert('P')
|
|
output.save(filename, 'png', optimize=True, pnginfo=pnginfo)
|
|
|
|
class State:
|
|
def __init__(self, dmi, name, *, loop=LOOP_UNLIMITED, rewind=False, movement=False, dirs=1):
|
|
self.dmi = dmi
|
|
self.name = name
|
|
self.loop = loop
|
|
self.rewind = rewind
|
|
self.movement = movement
|
|
self.dirs = dirs
|
|
|
|
self._nframes = None # used during loading only
|
|
self.frames = []
|
|
self.delays = []
|
|
self.hotspots = None
|
|
|
|
@property
|
|
def framecount(self):
|
|
if self._nframes is not None:
|
|
return self._nframes
|
|
else:
|
|
return len(self.frames) // self.dirs
|
|
|
|
def frame(self, image, *, delay=1):
|
|
assert image.size == (self.dmi.width, self.dmi.height)
|
|
self.delays.append(delay)
|
|
self.frames.append(image)
|
|
|
|
def hotspot(self, first_frame, x, y):
|
|
if self.hotspots is None:
|
|
self.hotspots = [None] * self.framecount
|
|
for i in range(first_frame, self.framecount):
|
|
self.hotspots[i] = x, y
|
|
|
|
def _frame_index(self, frame=0, dir=None):
|
|
ofs = DIR_ORDER.index(DIR_NAMES[dir])
|
|
if ofs >= self.dirs:
|
|
ofs = 0
|
|
return frame * self.dirs + ofs
|
|
|
|
def get_frame(self, *args, **kwargs):
|
|
return self.frames[self._frame_index(*args, **kwargs)]
|
|
|
|
def escape(text):
|
|
assert '\\' not in text and '"' not in text
|
|
return f'"{text}"'
|
|
|
|
def unescape(text, quote='"'):
|
|
if text == 'null':
|
|
return None
|
|
if not (text.startswith(quote) and text.endswith(quote)):
|
|
raise ValueError(text)
|
|
text = text[1:-1]
|
|
assert '\\' not in text and quote not in text
|
|
return text
|
|
|
|
def parse_num(value):
|
|
if '.' in value:
|
|
return float(value)
|
|
return int(value)
|
|
|
|
def parse_bool(value):
|
|
if value not in ('0', '1'):
|
|
raise ValueError(value)
|
|
return value == '1'
|
|
|
|
if __name__ == '__main__':
|
|
# test: can we load every DMI in the tree
|
|
import os
|
|
|
|
count = 0
|
|
for dirpath, dirnames, filenames in os.walk('.'):
|
|
if '.git' in dirnames:
|
|
dirnames.remove('.git')
|
|
for filename in filenames:
|
|
if filename.endswith('.dmi'):
|
|
Dmi.from_file(os.path.join(dirpath, filename))
|
|
count += 1
|
|
|
|
print(f"Successfully parsed {count} dmi files")
|