mirror of
https://github.com/SPLURT-Station/S.P.L.U.R.T-Station-13.git
synced 2025-12-09 16:07:40 +00:00
Merge pull request #37976 from AutomaticFrenzy/patch/dmi-merger
Add a Python script to fix DMI conflicts
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
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ automatically by GUI tools like TortoiseGit and GitHub for Windows, but
|
||||
## Current Hooks
|
||||
|
||||
* **Pre-commit**: Runs [mapmerge2] on changed maps, if any.
|
||||
* **DMI merger**: Attempts to [fix icon conflicts] when performing a git merge.
|
||||
If it succeeds, the file is marked merged. If it fails, it logs what states
|
||||
are still in conflict and adds them to the .dmi file, where the desired
|
||||
resolution can be chosen.
|
||||
|
||||
## Adding New Hooks
|
||||
|
||||
@@ -34,3 +38,4 @@ version of Python is found.
|
||||
[merge drivers]: https://git-scm.com/docs/gitattributes#_performing_a_three_way_merge
|
||||
[Git for Windows]: https://gitforwindows.org/
|
||||
[mapmerge2]: ../mapmerge2/README.md
|
||||
[fix icon conflicts]: ../mapmerge2/merge_driver_dmi.py
|
||||
|
||||
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 "$@"
|
||||
@@ -10,5 +10,7 @@ for %%f in (*.merge) do (
|
||||
|
||||
driver = tools/hooks/%%f %%P %%O %%A %%B %%L >> ..\..\.git\config
|
||||
)
|
||||
echo Installing Python dependencies
|
||||
python -m pip install -r ..\mapmerge2\requirements.txt
|
||||
echo Done
|
||||
pause
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
shopt -s nullglob
|
||||
cd "$(dirname "$0")"
|
||||
for f in *.hook; do
|
||||
@@ -9,4 +10,6 @@ for f in *.merge; do
|
||||
echo Installing merge driver: ${f%.merge}
|
||||
git config --replace-all merge.${f%.merge}.driver "tools/hooks/$f %P %O %A %B %L"
|
||||
done
|
||||
echo Installing Python dependencies
|
||||
./python.sh -m pip install -r ../mapmerge2/requirements.txt
|
||||
echo "Done"
|
||||
|
||||
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