-
Notifications
You must be signed in to change notification settings - Fork 0
/
flp_diff.py
377 lines (336 loc) · 13.3 KB
/
flp_diff.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
############################################
# flp_diff.py - diff and merge parsed FLP projects
# Feb 2022, Sam Boyer
# Part of the FLPX project
############################################
from typing import Any, Callable, Optional
from flp_read import loadFLP
import sys
import fl_helpers
from collections import defaultdict
class ArrangementChange:
state: str # 'added|deleted|modified|moved' (modified takes priority over moved)
index: Optional[int] = None
data: Optional[dict[str, Any]] = None
def diff_arrangement(
arr1: dict[str, Any], arr2: dict[str, Any]
) -> list[ArrangementChange]:
"""Returns information about the differences between two versions of an
arrangement.
Limitations/assumptions:
- assumes pattern/channel indexes between the two channels are identical.
TODO detect changes in pattern/channel indexes, resolve in an earlier function
- moving a clip slightly sideways counts as deletion and reinsertion
- assumes time resolution is the same
- ignores changes to track names/states
Returns: a list of changes. changes are of the form
{
state: 'added|deleted|modified|moved' (modified takes priority over moved)
index: int (the index of this item in arr1. None if it's a new item)
data: PlaylistItem (optional)
}s
TODO consider making 'muted/unmuted' a state type rather than just modified?
"""
items2Dict = defaultdict(list) # keyed on clipID and start time.
# values are arrays of hits (bc same clip can be on same tick multiple times)
for item in arr2["items"]:
key = (item["type"], item["clipIndex"], item["start"])
items2Dict[key].append(item)
changes = []
for i in range(len(arr1["items"])):
item = arr1["items"][i]
key = (item["type"], item["clipIndex"], item["start"])
if key in items2Dict:
matches = items2Dict[key]
if len(matches) > 1:
# find the closest one in y coordinates(for more accurate linking)
dists = [abs(item["track"] - i2["track"]) for i2 in matches]
matchI = dists.index(min(dists))
else:
matchI = 0
match = matches[matchI]
del items2Dict[key][matchI] # so it's no longer 'added'
# now determine if it's changed
# (we already checked start, type, index. leaves length, track, clip start,end, status)
clipStartA = fl_helpers.normalise_clip_start(item["clipStart"])
clipStartB = fl_helpers.normalise_clip_start(match["clipStart"])
hasMoved = item["track"] != match["track"]
isModified = (
item["length"] != match["length"]
or clipStartA
!= clipStartB # i dont think clipEnd can change while clipStart/length stay the same
or item["muted"] != match["muted"]
)
if isModified or hasMoved:
changes.append(
ArrangementChange(
state="modified" if isModified else "moved",
index=i,
data=match,
)
)
else: # item doesn't appear in arr2
changes.append(
ArrangementChange(
state="deleted",
index=i,
)
)
# add remaining arr2 items as added
for items in items2Dict.values():
for item in items: # remember the dict contains lists!
changes.append(ArrangementChange(state="added", data=item))
return changes
def debug_describe_arrangement_diff_verbose(
project, arrangement, changes: list[ArrangementChange]
):
for change in changes:
# TODO handle ghost patterns
if "index" in change:
item = arrangement["items"][change.index]
name = (
fl_helpers.getNameOfPattern(project, item["clipIndex"])
if item["type"] == "pattern"
else fl_helpers.channel_i_name(project, item["clipIndex"])
)
elif "data" in change:
name = (
fl_helpers.getNameOfPattern(project, change.data["clipIndex"])
if change.data["type"] == "pattern"
else fl_helpers.channel_i_name(project, change.data["clipIndex"])
)
if change.state == "added":
print(
"{} added at {}".format(
name, fl_helpers.ticks_to_BST(project, change.data["start"])
)
)
elif change.state == "deleted":
print(
"{} at {} deleted".format(
name, fl_helpers.ticks_to_BST(project, item["start"])
)
)
pass
elif change.state == "modified":
print(
"{} at {} modified".format(
name, fl_helpers.ticks_to_BST(project, change.data["start"])
)
)
elif change.state == "moved":
print(
"{} at {} moved from track {} to track {}".format(
name,
fl_helpers.ticks_to_BST(project, item["start"]),
item["track"],
change.data["track"],
)
)
def debug_describe_arrangement_diff_summary(arrangement, changes):
numAdded = numDeleted = numModified = numMoved = 0
numItems = len(arrangement["items"])
print(numItems)
for change in changes:
if change.state == "added":
numAdded += 1
elif change.state == "deleted":
numDeleted += 1
elif change.state == "modified":
numModified += 1
elif change.state == "moved":
numMoved += 1
print("{} clips ({}%) added".format(numAdded, round((numAdded / numItems * 100))))
print(
"{} clips ({}%) deleted".format(
numDeleted, round((numDeleted / numItems * 100))
)
)
print(
"{} clips ({}%) modified".format(
numModified, round((numModified / numItems * 100))
)
)
print("{} clips ({}%) moved".format(numMoved, round((numMoved / numItems * 100))))
def resolve_conflict_default(
arrangement, changeA: ArrangementChange, changeB: ArrangementChange
) -> list[dict[str, Any]]:
"""Resolve two conflicting actions, without user interaction."""
# table of what actions to take based on which conflicts
actionTable = {
"added": {
"added": "maybeAddBoth",
"deleted": "error",
"modified": "error",
"moved": "error",
},
"deleted": {
"added": "error",
"deleted": "delete",
"modified": "delete", # TODO is this always wanted?
"moved": "delete", # ^
},
"modified": {
"added": "error",
"deleted": "delete",
"modified": "twoModify",
"moved": "maybeMoveAndModify",
},
"moved": {
"added": "error",
"deleted": "delete",
"modified": "maybeMoveAndModify",
"moved": "A",
},
}
action = actionTable[changeA.state][changeB.state]
if action == "error":
raise Exception("invalid item change comparison")
elif action == "delete":
return []
elif action == "maybeAddBoth":
# if clips are identical, add only one, otherwise add both
# (start/clipID already checked)
itemA = changeA.data
itemB = changeB.data
clipsIdentical = (itemA["length"] == itemB["length"]) and (
fl_helpers.normalise_clip_start(itemA["clipStart"])
== fl_helpers.normalise_clip_start(itemB["clipStart"])
)
if clipsIdentical:
return [itemA]
else:
return [itemA, itemB] # TODO make this configurable (ie add both or prompt)
elif action == "A":
return [changeA.data]
elif action == "maybeMoveAndModify":
# if the modify doesn't move the clip, apply both and move it
theMove = changeA if changeA.state == "moved" else changeB
theModify = changeA if changeA.state == "modified" else changeB
theOriginal = arrangement["items"][theModify["index"]]
if theModify["data"]["track"] == theOriginal["track"]:
# apply the move to the modified version
theModify["data"]["track"] = theMove["data"]["track"]
# (if both have moved, just use theModify)
return [theModify["data"]]
elif action == "twoModify":
theOriginal = arrangement["items"][changeA.index]
# merge the changes into changeA
# attributes that may have changed
clipAttribs = [
"start",
"length",
"track",
"clipStart",
"clipEnd",
"muted",
"selected",
]
def handleClipAttrib(attrib):
orig = theOriginal[attrib]
a = changeA.data[attrib]
b = changeB.data[attrib]
aChanged = a == orig
bChanged = b == orig
if a == b:
return # both versions have the same attrib; do nothing
elif aChanged and bChanged:
# UH OH both clips have been changed in a non-mergeable way. just use A's attrib for now
return [changeA.data]
elif bChanged:
# B changed and A didn't; replace A's attrib
changeA.data[attrib] = b
else:
return # only A changed, do nothing
for attrib in clipAttribs:
handleClipAttrib(attrib)
return [changeA.data]
def merge_arrangement_changes(
arrangement: dict[str, Any],
changesA: list[ArrangementChange],
changesB: list[ArrangementChange],
resolveConflict: Callable[
[ArrangementChange, ArrangementChange], list[dict[str, Any]]
],
) -> dict[str, Any]:
"""Returns a new arrangement incorporating both sets of changes.
Does ??? upon merge conflicts
Limitations:
- discards playlist track names/customisation
Inputs:
resolveConflict :: (Change, Change) => [PlaylistItem]
"""
# annotate arrangement with changesA
# (also collect added clips)
addedA = defaultdict(list)
for change in changesA:
if change.state == "added":
item = change.data
key = (item["type"], item["clipIndex"], item["start"])
addedA[key].append(change)
else:
arrangement["items"][change.index]["change"] = change
# loop over changesB, look for conflicts
newItems = []
for change in changesB:
if change.state == "added":
item = change.data
key = (item["type"], item["clipIndex"], item["start"])
if key in addedA:
# if new item is identical (same start pl start/length, startPos)
newItems += resolveConflict(arrangement, addedA[key][0], change)
del addedA[key][0]
pass
else:
newItems.append(item)
else:
# if the item has already been changed, use the precedence table
if "change" in arrangement["items"][change.index]:
newItems += resolveConflict(
arrangement, arrangement["items"][change.index]["change"], change
)
arrangement["items"][change.index]["deleted"] = True
# finally loop over the arrangement and apply pending changes
for item in arrangement["items"]:
if "deleted" in item:
continue
if "change" in item:
if item["change"]["state"] == "deleted":
continue
else:
# state is moved or modified - just replace with the new data
newItems.append(item["change"]["data"])
else: # item is unchanged
newItems.append(item)
# add new items from A and B
newItems += [change.data for changes in addedA.values() for change in changes]
return {
"items": newItems,
"tracks": arrangement["tracks"], # TODO actually merge tracks
}
if __name__ == "__main__":
# file1 = sys.argv[-2]
# file2 = sys.argv[-1]
# project1 = loadFLP(file1)
# project2 = loadFLP(file2)
# changes = diff_arrangement(project1['arrangements'][0], project2['arrangements'][0])
# debug_describe_arrangement_diff_verbose(project1, project1['arrangements'][0], changes)
# debug_describe_arrangement_diff_summary(project1['arrangements'][0], changes)
# usage: python flp_diff.py [...] original.flp edited_a.flp edited_b.flp
projO = loadFLP(sys.argv[-3])
projA = loadFLP(sys.argv[-2])
projB = loadFLP(sys.argv[-1])
changesA = diff_arrangement(projO["arrangements"][0], projA["arrangements"][0])
changesB = diff_arrangement(projO["arrangements"][0], projB["arrangements"][0])
print("======")
print(len(projO["arrangements"][0]["items"]))
print("======")
# for c in changesA:
# if 'index' in c: print(c['index'])
# for c in changesB:
# if 'index' in c: print(c['index'])
newArrangement = merge_arrangement_changes(
projO["arrangements"][0], changesA, changesB, resolve_conflict_default
)
for item in newArrangement["items"]:
print(item)