tictacsync 1.0.2a0__py3-none-any.whl → 1.2.0b0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of tictacsync might be problematic. Click here for more details.
- tictacsync/device_scanner.py +5 -24
- tictacsync/entry.py +35 -38
- tictacsync/mamconf.py +175 -0
- tictacsync/mamsync.py +99 -172
- tictacsync/multi2polywav.py +3 -1
- tictacsync/newmix.py +217 -57
- tictacsync/timeline.py +137 -84
- tictacsync/yaltc.py +2 -2
- {tictacsync-1.0.2a0.dist-info → tictacsync-1.2.0b0.dist-info}/METADATA +4 -4
- tictacsync-1.2.0b0.dist-info/RECORD +19 -0
- {tictacsync-1.0.2a0.dist-info → tictacsync-1.2.0b0.dist-info}/entry_points.txt +2 -0
- tictacsync-1.0.2a0.dist-info/RECORD +0 -18
- {tictacsync-1.0.2a0.dist-info → tictacsync-1.2.0b0.dist-info}/LICENSE +0 -0
- {tictacsync-1.0.2a0.dist-info → tictacsync-1.2.0b0.dist-info}/WHEEL +0 -0
- {tictacsync-1.0.2a0.dist-info → tictacsync-1.2.0b0.dist-info}/top_level.txt +0 -0
tictacsync/newmix.py
CHANGED
|
@@ -1,19 +1,99 @@
|
|
|
1
|
-
import os, itertools, argparse, ffmpeg, tempfile
|
|
1
|
+
import os, itertools, argparse, ffmpeg, tempfile, platformdirs
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
from loguru import logger
|
|
4
|
-
import shutil, sys, re, sox
|
|
4
|
+
import shutil, sys, re, sox, configparser
|
|
5
5
|
from pprint import pformat
|
|
6
6
|
from rich import print
|
|
7
7
|
|
|
8
|
+
try:
|
|
9
|
+
from . import mamconf
|
|
10
|
+
except:
|
|
11
|
+
import mamconf
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
LUA = True
|
|
8
15
|
OUT_DIR_DEFAULT = 'SyncedMedia'
|
|
9
16
|
MCCDIR = 'SyncedMulticamClips'
|
|
10
17
|
SEC_DELAY_CHANGED_SND = 10 #sec, SND_DIR changed if diff time is bigger
|
|
11
18
|
DEL_TEMP = True
|
|
19
|
+
CONF_FILE = 'mamsync.cfg'
|
|
12
20
|
|
|
13
21
|
logger.level("DEBUG", color="<yellow>")
|
|
14
22
|
logger.remove()
|
|
15
|
-
# logger.add(sys.stdout, filter=lambda r: r["function"] == "
|
|
16
|
-
# logger.add(sys.stdout, filter=lambda r: r["function"] == "
|
|
23
|
+
# logger.add(sys.stdout, filter=lambda r: r["function"] == "main")
|
|
24
|
+
# logger.add(sys.stdout, filter=lambda r: r["function"] == "find_SND_vids_pairs_in_udir")
|
|
25
|
+
|
|
26
|
+
DAVINCI_RESOLVE_SCRIPT_LOCATION = '/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Utility/'
|
|
27
|
+
DAVINCI_RESOLVE_SCRIPT_TEMPLATE = \
|
|
28
|
+
"""def ClipWithFilePath(file_path):
|
|
29
|
+
media_pool = app.GetResolve().GetProjectManager().GetCurrentProject().GetMediaPool()
|
|
30
|
+
queue = [media_pool.GetRootFolder()]
|
|
31
|
+
while len(queue) > 0:
|
|
32
|
+
current = queue.pop(0)
|
|
33
|
+
queue += current.GetSubFolderList()
|
|
34
|
+
got_it = [cl for cl in current.GetClipList()
|
|
35
|
+
if cl.GetClipProperty('File Path') == file_path]
|
|
36
|
+
# print(got_it)
|
|
37
|
+
if got_it != []:
|
|
38
|
+
# print('got it')
|
|
39
|
+
return got_it[0]
|
|
40
|
+
|
|
41
|
+
def findAndReplace(old_file, new_file):
|
|
42
|
+
clip_with_old_file = ClipWithFilePath(old_file)
|
|
43
|
+
if clip_with_old_file == None:
|
|
44
|
+
print(f'did not find clip with path {old_file}')
|
|
45
|
+
sys.exit(0)
|
|
46
|
+
else:
|
|
47
|
+
clip_with_old_file.ReplaceClip(new_file)
|
|
48
|
+
cfp, cn = [clip_with_old_file.GetClipProperty(p) for p in ['File Path', 'Clip Name']]
|
|
49
|
+
if cfp == new_file:
|
|
50
|
+
print(f'Loaded {cn} with a new sound track')
|
|
51
|
+
else:
|
|
52
|
+
print(f'findAndReplace {old_file} -> {new_file} failed')
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
# python is deprecated... Lua is the way to go
|
|
56
|
+
|
|
57
|
+
DAVINCI_RESOLVE_SCRIPT_TEMPLATE_LUA = """local function ClipWithPartialPath(partial_path)
|
|
58
|
+
local media_pool = app:GetResolve():GetProjectManager():GetCurrentProject():GetMediaPool()
|
|
59
|
+
local queue = {media_pool:GetRootFolder()}
|
|
60
|
+
while #queue > 0 do
|
|
61
|
+
local current = table.remove(queue, 1)
|
|
62
|
+
local subfolders = current:GetSubFolderList()
|
|
63
|
+
for _, folder in ipairs(subfolders) do
|
|
64
|
+
table.insert(queue, folder)
|
|
65
|
+
end
|
|
66
|
+
local got_it = {}
|
|
67
|
+
local clips = current:GetClipList()
|
|
68
|
+
for _, cl in ipairs(clips) do
|
|
69
|
+
if string.find(cl:GetClipProperty('File Path'), partial_path) then
|
|
70
|
+
table.insert(got_it, cl)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
if #got_it > 0 then
|
|
74
|
+
return got_it[1]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
local function findAndReplace(old_file, new_file)
|
|
80
|
+
local clip_with_old_file = ClipWithPartialPath(old_file)
|
|
81
|
+
if clip_with_old_file == nil then
|
|
82
|
+
print('did not find clip with path ' .. old_file)
|
|
83
|
+
else
|
|
84
|
+
clip_with_old_file:ReplaceClip(new_file)
|
|
85
|
+
local cfp = clip_with_old_file:GetClipProperty('File Path')
|
|
86
|
+
local cn = clip_with_old_file:GetClipProperty('Clip Name')
|
|
87
|
+
if cfp == new_file then
|
|
88
|
+
print('Loaded ' .. cn .. ' with a new sound track')
|
|
89
|
+
else
|
|
90
|
+
print('findAndReplace ' .. old_file .. ' -> ' .. new_file .. ' failed')
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
local changes = {
|
|
96
|
+
"""
|
|
17
97
|
|
|
18
98
|
video_extensions = \
|
|
19
99
|
"""webm mkv flv flv vob ogv ogg drc gif gifv mng avi mov
|
|
@@ -42,11 +122,12 @@ def is_synced_video(f):
|
|
|
42
122
|
# logger.debug('ok_ext: %s ok_folders: %s'%(ok_ext, ok_folders))
|
|
43
123
|
return ok_ext and ok_folders
|
|
44
124
|
|
|
45
|
-
def
|
|
46
|
-
# look for matching video name and SND
|
|
125
|
+
def find_SND_vids_pairs_in_udir(top):
|
|
126
|
+
# look for matching video name and SND in unique directory
|
|
127
|
+
# in alongside mode (vs MAM mode)
|
|
47
128
|
# eg: IMG04.mp4 and IMG04_SND
|
|
48
129
|
# maybe IMG04v2.mp4 if audio changed before (than it will be IMG04v3.mp4)
|
|
49
|
-
# returns list of matches
|
|
130
|
+
# returns list of (SND, vid) matches
|
|
50
131
|
# recursively search from 'top' argument
|
|
51
132
|
vids = []
|
|
52
133
|
SNDs = []
|
|
@@ -80,59 +161,64 @@ def find_SND_vids_pairs_in_dir(top):
|
|
|
80
161
|
logger.debug('matches: %s'%pformat(matches))
|
|
81
162
|
return matches
|
|
82
163
|
|
|
164
|
+
def find_SND_vids_pairs_in_dual_dir(synced_root, snd_root):
|
|
165
|
+
# look for matching video name and SND in unique directory
|
|
166
|
+
# in alongside mode (vs MAM mode)
|
|
167
|
+
# eg: IMG04.mp4 and directory IMG04_SND
|
|
168
|
+
# maybe IMG04v2.mp4 if audio changed before (than it will be IMG04v3.mp4)
|
|
169
|
+
# returns list of (SND, vid) matches
|
|
170
|
+
# recursively search from 'top' argument
|
|
171
|
+
vids = []
|
|
172
|
+
SNDs = []
|
|
173
|
+
print(f'Will look for new mix in {snd_root} compared to vids in {synced_root}')
|
|
174
|
+
for (root,dirs,files) in os.walk(synced_root):
|
|
175
|
+
for f in files:
|
|
176
|
+
pf = Path(root)/f
|
|
177
|
+
if pf.suffix[1:].lower() in video_extensions:
|
|
178
|
+
if not pf.is_symlink():
|
|
179
|
+
vids.append(pf)
|
|
180
|
+
for (root,dirs,files) in os.walk(snd_root):
|
|
181
|
+
for d in dirs:
|
|
182
|
+
if d[-4:] == '_SND':
|
|
183
|
+
SNDs.append(Path(root)/d)
|
|
184
|
+
logger.debug('vids %s SNDs %s'%(pformat(vids), pformat(SNDs)))
|
|
185
|
+
matches = []
|
|
186
|
+
def _names_match(vidname, SND_name):
|
|
187
|
+
# vidname is a str and has no extension
|
|
188
|
+
# vidname could have vNN suffix as in DSC_8064v31 so matches DSC_8064
|
|
189
|
+
if vidname == SND_name: # no suffix presents
|
|
190
|
+
return True
|
|
191
|
+
m = re.match(SND_name + r'v(\d+)', vidname)
|
|
192
|
+
if m != None:
|
|
193
|
+
logger.debug('its a natch and N= %s'%m.groups()[0])
|
|
194
|
+
return m != None
|
|
195
|
+
for pair in list(itertools.product(SNDs, vids)):
|
|
196
|
+
# print(pair)
|
|
197
|
+
SND, vid = pair # Paths
|
|
198
|
+
vidname, ext = vid.name.split('.') # string
|
|
199
|
+
if _names_match(vidname, SND.name[:-4]):
|
|
200
|
+
logger.debug('SND %s matches video %s'%(
|
|
201
|
+
Path('').joinpath(*SND.parts[-2:]),
|
|
202
|
+
Path('').joinpath(*vid.parts[-3:])))
|
|
203
|
+
matches.append(pair) # list of Paths
|
|
204
|
+
logger.debug('matches: %s'%pformat(matches))
|
|
205
|
+
return matches
|
|
206
|
+
|
|
83
207
|
def parse_and_check_arguments():
|
|
84
208
|
# parses directories from command arguments
|
|
85
209
|
# check for consistencies and warn user and exits,
|
|
86
|
-
#
|
|
87
|
-
# proxies_dir, originals_dir, audio_dir, both_audio_vid, scan_only
|
|
210
|
+
# returns parser.parse_args()
|
|
88
211
|
parser = argparse.ArgumentParser()
|
|
89
|
-
|
|
90
|
-
# nargs=1,
|
|
91
|
-
# dest='video_dirs',
|
|
92
|
-
# help='Where proxy clips and/or originals are stored')
|
|
93
|
-
# parser.add_argument('-a',
|
|
94
|
-
# nargs=1,
|
|
95
|
-
# dest='audio_dir',
|
|
96
|
-
# help='Contains newly changed mix files')
|
|
97
|
-
parser.add_argument('-b',
|
|
212
|
+
parser.add_argument('-u',
|
|
98
213
|
nargs=1,
|
|
99
|
-
dest='
|
|
100
|
-
help='Directory scanned for both audio and video, when tictacsync
|
|
214
|
+
dest='unique_directory',
|
|
215
|
+
help='Directory scanned for both audio and video, when tictacsync is used rather than mamsync.')
|
|
101
216
|
parser.add_argument('--dry',
|
|
102
217
|
action='store_true',
|
|
103
218
|
dest='scan_only',
|
|
104
219
|
help="Just display changed audio, don't merge")
|
|
105
220
|
args = parser.parse_args()
|
|
106
221
|
logger.debug('args %s'%args)
|
|
107
|
-
# ok cases:
|
|
108
|
-
# -p -o -a + no -b
|
|
109
|
-
# -o -a + no -b
|
|
110
|
-
# args_set = [args.originals_dir != None,
|
|
111
|
-
# args.audio_dir != None,
|
|
112
|
-
# args.both_audio_vid != None,
|
|
113
|
-
# ]
|
|
114
|
-
# p, o, a, b = args_set
|
|
115
|
-
# check that argument -b (both_audio_vid) is used alone
|
|
116
|
-
# if b and any([o, a, p]):
|
|
117
|
-
# print("\nDon't specify other argument than -b if both audio and video searched in the same directory.\n")
|
|
118
|
-
# parser.print_help(sys.stderr)
|
|
119
|
-
# sys.exit(0)
|
|
120
|
-
# check that if proxies (-p) are specified, orginals too (-o)
|
|
121
|
-
# if p and not o:
|
|
122
|
-
# print("\nIf proxies directory is specified, so should originals directory.\n")
|
|
123
|
-
# parser.print_help(sys.stderr)
|
|
124
|
-
# sys.exit(0)
|
|
125
|
-
# check that -o and -a are used together
|
|
126
|
-
# if not b and not (o and a):
|
|
127
|
-
# print("\nAt least originals and audio directories must be given (-o and -a) when audio and video are in different dir.\n")
|
|
128
|
-
# parser.print_help(sys.stderr)
|
|
129
|
-
# sys.exit(0)
|
|
130
|
-
# # work in progress (aug 2025), so limit to -b:
|
|
131
|
-
# if not b :
|
|
132
|
-
# print("\nFor now, only -b argument is supported (a directory scanned for both audio and video) .\n")
|
|
133
|
-
# parser.print_help(sys.stderr)
|
|
134
|
-
# sys.exit(0)
|
|
135
|
-
# list of singletons, so flatten. Keep None and False as is
|
|
136
222
|
if args.scan_only:
|
|
137
223
|
print('Sorry, --dry option not implemented yet, bye.')
|
|
138
224
|
sys.exit(0)
|
|
@@ -147,7 +233,7 @@ def get_recent_mix(SND_dir, vid):
|
|
|
147
233
|
logger.debug(f'wav_files {wav_files} in {SND_dir}')
|
|
148
234
|
def is_mix(p):
|
|
149
235
|
re_result = re.match(r'mix([lrLR])*', p.name)
|
|
150
|
-
logger.debug(f'for {p.name} re_result {re_result}')
|
|
236
|
+
logger.debug(f'for {p.name} re_result looking for mix {re_result}')
|
|
151
237
|
return re_result is not None
|
|
152
238
|
mix_files = [p for p in wav_files if is_mix(p)]
|
|
153
239
|
if len(mix_files) == 0:
|
|
@@ -211,6 +297,9 @@ def _change_audio4video(audio_path: Path, video: Path):
|
|
|
211
297
|
Replace audio in video (argument) by the audio contained in
|
|
212
298
|
audio_path (argument) returns nothing
|
|
213
299
|
If name has version number, bump it (DSC_8064v13.MOV -> DSC_8064v14.MOV)
|
|
300
|
+
Returns string tuple of old and new video files
|
|
301
|
+
old file in incomplete e.g.: ../DSC_8064 without version and ext
|
|
302
|
+
new file is complete
|
|
214
303
|
|
|
215
304
|
"""
|
|
216
305
|
vidname, video_ext = video.name.split('.')
|
|
@@ -223,14 +312,21 @@ def _change_audio4video(audio_path: Path, video: Path):
|
|
|
223
312
|
# no suffix, add one
|
|
224
313
|
out_path = video.parent / f'{vidname}v2.{video_ext}'
|
|
225
314
|
out_n = _pathname(out_path)
|
|
315
|
+
# for Resolve search
|
|
316
|
+
partial_filename = str(video.parent / vidname)
|
|
226
317
|
else:
|
|
227
318
|
base, number_str = m.groups()
|
|
228
319
|
logger.debug(f'base {base}, number_str {number_str}')
|
|
229
320
|
up_tick = 1 + int(number_str)
|
|
230
321
|
out_path = video.parent / f'{base}v{up_tick}.{video_ext}'
|
|
231
322
|
out_n = _pathname(out_path)
|
|
323
|
+
# for Resolve search
|
|
324
|
+
partial_filename = str(video.parent / base)
|
|
232
325
|
print(f'Video [bold]{video}[/bold] \nhas new sound and is now [bold]{out_path}[/bold]')
|
|
233
|
-
|
|
326
|
+
logger.debug(f'partial filname {partial_filename}')
|
|
327
|
+
old_file = str(video)
|
|
328
|
+
new_file = str(out_path)
|
|
329
|
+
# video.unlink() # remove old one
|
|
234
330
|
# building args for debug purpose only:
|
|
235
331
|
ffmpeg_args = (
|
|
236
332
|
ffmpeg
|
|
@@ -264,6 +360,7 @@ def _change_audio4video(audio_path: Path, video: Path):
|
|
|
264
360
|
print(e)
|
|
265
361
|
print(e.stderr.decode('UTF-8'))
|
|
266
362
|
sys.exit(1)
|
|
363
|
+
return partial_filename, new_file
|
|
267
364
|
|
|
268
365
|
def _sox_combine(paths) -> Path:
|
|
269
366
|
"""
|
|
@@ -299,15 +396,49 @@ def _sox_combine(paths) -> Path:
|
|
|
299
396
|
(merged_duration, nchan))
|
|
300
397
|
return out_file_handle
|
|
301
398
|
|
|
399
|
+
def generate_script(new_mixes):
|
|
400
|
+
if LUA:
|
|
401
|
+
script = DAVINCI_RESOLVE_SCRIPT_TEMPLATE_LUA
|
|
402
|
+
postlude = ''
|
|
403
|
+
for a,b in new_mixes:
|
|
404
|
+
postlude += '{"%s","%s"},\n'%(a,b)
|
|
405
|
+
# postlude += f"('{a}','{b}'),\n"
|
|
406
|
+
postlude += '}\n\nfor _, pair in ipairs(changes) do\n'
|
|
407
|
+
postlude += ' findAndReplace(pair[1], pair[2])\n'
|
|
408
|
+
postlude += ' os.remove(pair[1])\n'
|
|
409
|
+
postlude += 'end\n'
|
|
410
|
+
# postlude += 'os.remove(pair[1])\n'
|
|
411
|
+
else: # python
|
|
412
|
+
script = DAVINCI_RESOLVE_SCRIPT_TEMPLATE
|
|
413
|
+
postlude = '\nchanges = [\n'
|
|
414
|
+
for a,b in new_mixes:
|
|
415
|
+
postlude += '("%s","%s"),\n'%(a,b)
|
|
416
|
+
postlude += ']\n'
|
|
417
|
+
postlude += '[findAndReplace(a,b) for a, b in changes]\n\n'
|
|
418
|
+
# foo = '[findAndReplace(a,b) for a, b in changes\n'
|
|
419
|
+
# print('foo',foo)
|
|
420
|
+
# print(postlude + foo)
|
|
421
|
+
return script + postlude
|
|
302
422
|
|
|
303
423
|
def main():
|
|
304
|
-
# proxies_dir, originals_dir, audio_dir,
|
|
424
|
+
# proxies_dir, originals_dir, audio_dir, unique_directory, scan_only = \
|
|
305
425
|
# parse_and_check_arguments()
|
|
306
426
|
args = parse_and_check_arguments()
|
|
307
|
-
|
|
427
|
+
if args.unique_directory != None:
|
|
428
|
+
# alongside
|
|
429
|
+
matching_pairs = find_SND_vids_pairs_in_udir(args.unique_directory[0])
|
|
430
|
+
else:
|
|
431
|
+
# MAM mode
|
|
432
|
+
raw_root, synced_root, snd_root, proxies = mamconf.get_proj(False)
|
|
433
|
+
if raw_root == None:
|
|
434
|
+
print('Error: without -u option, mamsync should be configured before using newmix')
|
|
435
|
+
sys.exit(0)
|
|
436
|
+
matching_pairs = find_SND_vids_pairs_in_dual_dir(synced_root, snd_root)
|
|
437
|
+
logger.debug(f'matching_pairs {pformat(matching_pairs)}')
|
|
438
|
+
changes = []
|
|
308
439
|
for SND_dir, vid in matching_pairs:
|
|
309
440
|
new_mix_files = get_recent_mix(SND_dir, vid)
|
|
310
|
-
|
|
441
|
+
logger.debug('new_mix_files: %s'%str(new_mix_files))
|
|
311
442
|
if new_mix_files != ():
|
|
312
443
|
logger.debug(f'new mixes {new_mix_files} in {SND_dir} for {vid.name}')
|
|
313
444
|
if len(new_mix_files) == 2:
|
|
@@ -315,9 +446,38 @@ def main():
|
|
|
315
446
|
logger.debug('stereo_wav: %s'%new_audio_wav)
|
|
316
447
|
else: # len == 1, mono wav file
|
|
317
448
|
new_audio_wav = new_mix_files[0]
|
|
318
|
-
_change_audio4video(new_audio_wav, vid)
|
|
319
|
-
|
|
449
|
+
old_file, new_file = _change_audio4video(new_audio_wav, vid)
|
|
450
|
+
changes.append((old_file, new_file))
|
|
451
|
+
if len(changes) == 0:
|
|
452
|
+
print('No new mix.')
|
|
453
|
+
sys.exit(0)
|
|
454
|
+
logger.debug(f'changes {pformat(changes)}')
|
|
455
|
+
script = generate_script(changes)
|
|
456
|
+
fist_vid, _ = changes[0]
|
|
457
|
+
fist_name = Path(fist_vid).name
|
|
458
|
+
title = f'New sound for {fist_name}'
|
|
459
|
+
new_sound_clips = [p for p in Path(DAVINCI_RESOLVE_SCRIPT_LOCATION).iterdir() if 'New sound for' in str(p)]
|
|
460
|
+
if len(new_sound_clips) != 0:
|
|
461
|
+
print('There is already some "New sound for" Resolve scripts')
|
|
462
|
+
for p in new_sound_clips:
|
|
463
|
+
print(p)
|
|
464
|
+
while ( res:=input("Is it OK to delete them? (you could also do it manually). Enter y/n: ").lower() ) not in {"y", "n"}: pass
|
|
465
|
+
if res == 'y':
|
|
466
|
+
[p.unlink() for p in new_sound_clips]
|
|
467
|
+
# title = str(fist_vid)
|
|
468
|
+
if len(changes) > 1:
|
|
469
|
+
title += ' and others'
|
|
470
|
+
if LUA:
|
|
471
|
+
script_path = Path(DAVINCI_RESOLVE_SCRIPT_LOCATION)/f'{title}.lua'
|
|
472
|
+
script += f'os.remove("{script_path}")\n'
|
|
473
|
+
else:
|
|
474
|
+
# python version is deprecated...
|
|
475
|
+
script_path = Path(DAVINCI_RESOLVE_SCRIPT_LOCATION)/f'{title}.py'
|
|
476
|
+
with open(script_path, 'w') as fh:
|
|
477
|
+
fh.write(script)
|
|
478
|
+
print(f'Wrote script {script_path}')
|
|
479
|
+
# print(script)
|
|
320
480
|
|
|
321
481
|
|
|
322
482
|
if __name__ == '__main__':
|
|
323
|
-
main()
|
|
483
|
+
main()
|