Browse Source

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.
master
Tad Hardesty 2 years ago
parent
commit
2106e3a1b2
5 changed files with 436 additions and 9 deletions
  1. +3
    -9
      .gitattributes
  2. +2
    -0
      tools/hooks/dmi.merge
  3. +253
    -0
      tools/mapmerge2/dmi.py
  4. +177
    -0
      tools/mapmerge2/merge_driver_dmi.py
  5. +1
    -0
      tools/mapmerge2/requirements.txt

+ 3
- 9
.gitattributes View File

@@ -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
- 0
tools/hooks/dmi.merge View File

@@ -0,0 +1,2 @@
#!/bin/bash
exec tools/hooks/python.sh -m merge_driver_dmi "$@"

+ 253
- 0
tools/mapmerge2/dmi.py View 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
- 0
tools/mapmerge2/merge_driver_dmi.py View 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
- 0
tools/mapmerge2/requirements.txt View File

@@ -1,2 +1,3 @@
pygit2==0.26.0
bidict==0.13.1
Pillow=5.1.0

Loading…
Cancel
Save