mirror of
https://github.com/fulpstation/fulpstation.git
synced 2025-12-09 16:09:15 +00:00
Add a Python script to fix DMI conflicts
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.
This commit is contained in:
12
.gitattributes
vendored
12
.gitattributes
vendored
@@ -1,12 +1,6 @@
|
||||
# dmm map merger hook
|
||||
# needs additional setup, see tools/mapmerge/install.txt
|
||||
*.dmm merge=merge-dmm
|
||||
|
||||
# dmi icon merger hook
|
||||
# needs additional setup, see tools/dmitool/merging.txt
|
||||
*.dmi merge=merge-dmi
|
||||
# merger hooks, run tools/hooks/install.bat or install.sh to set up
|
||||
*.dmm merge=dmm
|
||||
*.dmi merge=dmi
|
||||
|
||||
# force changelog merging to use union
|
||||
html/changelog.html merge=union
|
||||
|
||||
|
||||
|
||||
2
tools/hooks/dmi.merge
Normal file
2
tools/hooks/dmi.merge
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
exec tools/hooks/python.sh -m merge_driver_dmi "$@"
|
||||
253
tools/mapmerge2/dmi.py
Normal file
253
tools/mapmerge2/dmi.py
Normal file
@@ -0,0 +1,253 @@
|
||||
# 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")
|
||||
177
tools/mapmerge2/merge_driver_dmi.py
Normal file
177
tools/mapmerge2/merge_driver_dmi.py
Normal file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import dmi
|
||||
|
||||
def images_equal(left, right):
|
||||
if left.size != right.size:
|
||||
return False
|
||||
w, h = left.size
|
||||
left_load, right_load = left.load(), right.load()
|
||||
for y in range(0, h):
|
||||
for x in range(0, w):
|
||||
lpixel, rpixel = left_load[x, y], right_load[x, y]
|
||||
# quietly ignore changes where both pixels are fully transparent
|
||||
if lpixel != rpixel and (lpixel[3] != 0 or rpixel[3] != 0):
|
||||
return False
|
||||
return True
|
||||
|
||||
def states_equal(left, right):
|
||||
result = True
|
||||
|
||||
# basic properties
|
||||
for attr in ('loop', 'rewind', 'movement', 'dirs', 'delays', 'hotspots', 'framecount'):
|
||||
lval, rval = getattr(left, attr), getattr(right, attr)
|
||||
if lval != rval:
|
||||
result = False
|
||||
|
||||
# frames
|
||||
for (left_frame, right_frame) in zip(left.frames, right.frames):
|
||||
if not images_equal(left_frame, right_frame):
|
||||
result = False
|
||||
|
||||
return result
|
||||
|
||||
def key_of(state):
|
||||
return (state.name, state.movement)
|
||||
|
||||
def dictify(sheet):
|
||||
result = {}
|
||||
for state in sheet.states:
|
||||
k = key_of(state)
|
||||
if k in result:
|
||||
print(f" duplicate {k!r}")
|
||||
result[k] = state
|
||||
return result
|
||||
|
||||
def three_way_merge(base, left, right):
|
||||
base_dims = base.width, base.height
|
||||
if base_dims != (left.width, left.height) or base_dims != (right.width, right.height):
|
||||
print("Dimensions have changed:")
|
||||
print(f" Base: {base.width} x {base.height}")
|
||||
print(f" Ours: {left.width} x {left.height}")
|
||||
print(f" Theirs: {right.width} x {right.height}")
|
||||
return True, None
|
||||
|
||||
base_states, left_states, right_states = dictify(base), dictify(left), dictify(right)
|
||||
|
||||
new_left = {k: v for k, v in left_states.items() if k not in base_states}
|
||||
new_right = {k: v for k, v in right_states.items() if k not in base_states}
|
||||
new_both = {}
|
||||
conflicts = []
|
||||
for key, state in list(new_left.items()):
|
||||
in_right = new_right.get(key, None)
|
||||
if in_right:
|
||||
if states_equal(state, in_right):
|
||||
# allow it
|
||||
new_both[key] = state
|
||||
else:
|
||||
# generate conflict states
|
||||
print(f" C: {state.name!r}: added differently in both!")
|
||||
state.name = f"{state.name} !CONFLICT! left"
|
||||
conflicts.append(state)
|
||||
in_right.name = f"{state.name} !CONFLICT! right"
|
||||
conflicts.append(in_right)
|
||||
# don't add it a second time
|
||||
del new_left[key]
|
||||
del new_right[key]
|
||||
|
||||
final_states = []
|
||||
# add states that are currently in the base
|
||||
for state in base.states:
|
||||
in_left = left_states.get(key_of(state), None)
|
||||
in_right = right_states.get(key_of(state), None)
|
||||
left_equals = in_left and states_equal(state, in_left)
|
||||
right_equals = in_right and states_equal(state, in_right)
|
||||
|
||||
if not in_left and not in_right:
|
||||
# deleted in both left and right, it's just deleted
|
||||
print(f" {state.name!r}: deleted in both")
|
||||
elif not in_left:
|
||||
# left deletes
|
||||
print(f" {state.name!r}: deleted in left")
|
||||
if not right_equals:
|
||||
print(f" ... but modified in right")
|
||||
final_states.append(in_right)
|
||||
elif not in_right:
|
||||
# right deletes
|
||||
print(f" {state.name!r}: deleted in right")
|
||||
if not left_equals:
|
||||
print(f" ... but modified in left")
|
||||
final_states.append(in_left)
|
||||
elif left_equals and right_equals:
|
||||
# changed in neither
|
||||
#print(f"Same in both: {state.name!r}")
|
||||
final_states.append(state)
|
||||
elif left_equals:
|
||||
# changed only in right
|
||||
print(f" {state.name!r}: changed in left")
|
||||
final_states.append(in_right)
|
||||
elif right_equals:
|
||||
# changed only in left
|
||||
print(f" {state.name!r}: changed in right")
|
||||
final_states.append(in_left)
|
||||
elif states_equal(in_left, in_right):
|
||||
# changed in both, to the same thing
|
||||
print(f" {state.name!r}: changed same in both")
|
||||
final_states.append(in_left) # either or
|
||||
else:
|
||||
# changed in both
|
||||
name = state.name
|
||||
print(f" C: {name!r}: changed differently in both!")
|
||||
state.name = f"{name} !CONFLICT! base"
|
||||
conflicts.append(state)
|
||||
in_left.name = f"{name} !CONFLICT! left"
|
||||
conflicts.append(in_left)
|
||||
in_right.name = f"{name} !CONFLICT! right"
|
||||
conflicts.append(in_right)
|
||||
|
||||
# add states which both left and right added the same
|
||||
for key, state in new_both.items():
|
||||
print(f" {state.name!r}: added same in both")
|
||||
final_states.append(state)
|
||||
|
||||
# add states that are brand-new in the left
|
||||
for key, state in new_left.items():
|
||||
print(f" {state.name!r}: added in left")
|
||||
final_states.append(state)
|
||||
|
||||
# add states that are brand-new in the right
|
||||
for key, state in new_right.items():
|
||||
print(f" {state.name!r}: added in right")
|
||||
final_states.append(state)
|
||||
|
||||
final_states.extend(conflicts)
|
||||
merged = dmi.Dmi(base.width, base.height)
|
||||
merged.states = final_states
|
||||
return len(conflicts), merged
|
||||
|
||||
def main(path, original, left, right):
|
||||
print(f"Merging icon: {path}")
|
||||
|
||||
icon_orig = dmi.Dmi.from_file(original)
|
||||
icon_left = dmi.Dmi.from_file(left)
|
||||
icon_right = dmi.Dmi.from_file(right)
|
||||
|
||||
trouble, merged = three_way_merge(icon_orig, icon_left, icon_right)
|
||||
if merged:
|
||||
merged.to_file(left)
|
||||
if trouble:
|
||||
print("!!! Manual merge required!")
|
||||
if merged:
|
||||
print(" A best-effort merge was performed. You must edit the icon and remove all")
|
||||
print(" icon states marked with !CONFLICT!, leaving only the desired icon.")
|
||||
else:
|
||||
print(" The icon was totally unable to be merged, you must start with one version")
|
||||
print(" or the other and manually resolve the conflict.")
|
||||
print(" Information about which states conflicted is listed above.")
|
||||
return trouble
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 6:
|
||||
print("DMI merge driver called with wrong number of arguments")
|
||||
print(" usage: merge-driver-dmi %P %O %A %B %L")
|
||||
exit(1)
|
||||
|
||||
# "left" is also the file that ought to be overwritten
|
||||
_, path, original, left, right, conflict_size_marker = sys.argv
|
||||
exit(main(path, original, left, right))
|
||||
@@ -1,2 +1,3 @@
|
||||
pygit2==0.26.0
|
||||
bidict==0.13.1
|
||||
Pillow=5.1.0
|
||||
|
||||
Reference in New Issue
Block a user