tictacsync 1.4.0b0__py3-none-any.whl → 1.4.5b0__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/newmix.py DELETED
@@ -1,483 +0,0 @@
1
- import os, itertools, argparse, ffmpeg, tempfile, platformdirs
2
- from pathlib import Path
3
- from loguru import logger
4
- import shutil, sys, re, sox, configparser
5
- from pprint import pformat
6
- from rich import print
7
-
8
- try:
9
- from . import mamconf
10
- except:
11
- import mamconf
12
-
13
-
14
- LUA = True
15
- OUT_DIR_DEFAULT = 'SyncedMedia'
16
- MCCDIR = 'SyncedMulticamClips'
17
- SEC_DELAY_CHANGED_SND = 10 #sec, SND_DIR changed if diff time is bigger
18
- DEL_TEMP = True
19
- CONF_FILE = 'mamsync.cfg'
20
-
21
- logger.level("DEBUG", color="<yellow>")
22
- logger.remove()
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
- """
97
-
98
- video_extensions = \
99
- """webm mkv flv flv vob ogv ogg drc gif gifv mng avi mov
100
- qt wmv yuv rm rmvb viv asf mp4 m4p m4v mpg mp2 mpeg mpe
101
- mpv mpg mpeg m2v m4v svi 3gp 3g2 mxf roq nsv""".split() # from wikipedia
102
-
103
- def _pathname(tempfile_or_path) -> str:
104
- # utility for obtaining a str from different filesystem objects
105
- if isinstance(tempfile_or_path, str):
106
- return tempfile_or_path
107
- if isinstance(tempfile_or_path, Path):
108
- return str(tempfile_or_path)
109
- if isinstance(tempfile_or_path, tempfile._TemporaryFileWrapper):
110
- return tempfile_or_path.name
111
- else:
112
- raise Exception('%s should be Path or tempfile...'%tempfile_or_path)
113
-
114
- def is_synced_video(f):
115
- # True if name as video extension
116
- # and is under SyncedMedia or SyncedMulticamClips folders
117
- # f is a Path
118
- ext = f.suffix[1:] # removing leading '.'
119
- ok_ext = ext.lower() in video_extensions
120
- f_parts = f.parts
121
- ok_folders = OUT_DIR_DEFAULT in f_parts or MCCDIR in f_parts
122
- # logger.debug('ok_ext: %s ok_folders: %s'%(ok_ext, ok_folders))
123
- return ok_ext and ok_folders
124
-
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)
128
- # eg: IMG04.mp4 and IMG04_SND
129
- # maybe IMG04v2.mp4 if audio changed before (than it will be IMG04v3.mp4)
130
- # returns list of (SND, vid) matches
131
- # recursively search from 'top' argument
132
- vids = []
133
- SNDs = []
134
- for (root,dirs,files) in os.walk(top):
135
- for d in dirs:
136
- if d[-4:] == '_SND':
137
- SNDs.append(Path(root)/d)
138
- for f in files:
139
- if is_synced_video(Path(root)/f): # add being in SyncedMedia or SyncedMulticamClips folder
140
- vids.append(Path(root)/f)
141
- logger.debug('vids %s SNDs %s'%(pformat(vids), pformat(SNDs)))
142
- matches = []
143
- def _names_match(vidname, SND_name):
144
- # vidname is a str and has no extension
145
- # vidname could have vNN suffix as in DSC_8064v31 so matches DSC_8064
146
- if vidname == SND_name: # no suffix presents
147
- return True
148
- m = re.match(SND_name + r'v(\d+)', vidname)
149
- if m != None:
150
- logger.debug('its a natch and N= %s'%m.groups()[0])
151
- return m != None
152
- for pair in list(itertools.product(SNDs, vids)):
153
- # print(pair)
154
- SND, vid = pair # Paths
155
- vidname, ext = vid.name.split('.') # string
156
- if _names_match(vidname, SND.name[:-4]):
157
- logger.debug('SND %s matches video %s'%(
158
- Path('').joinpath(*SND.parts[-2:]),
159
- Path('').joinpath(*vid.parts[-3:])))
160
- matches.append(pair) # list of Paths
161
- logger.debug('matches: %s'%pformat(matches))
162
- return matches
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
-
207
- def parse_and_check_arguments():
208
- # parses directories from command arguments
209
- # check for consistencies and warn user and exits,
210
- # returns parser.parse_args()
211
- parser = argparse.ArgumentParser()
212
- parser.add_argument('-u',
213
- nargs=1,
214
- dest='unique_directory',
215
- help='Directory scanned for both audio and video, when tictacsync is used rather than mamsync.')
216
- parser.add_argument('--dry',
217
- action='store_true',
218
- dest='scan_only',
219
- help="Just display changed audio, don't merge")
220
- args = parser.parse_args()
221
- logger.debug('args %s'%args)
222
- if args.scan_only:
223
- print('Sorry, --dry option not implemented yet, bye.')
224
- sys.exit(0)
225
- return args
226
-
227
- def get_recent_mix(SND_dir, vid):
228
- # check if there are mixl, mixr or mix files in SND_dir
229
- # and return the paths if they are more recent than vid.
230
- # returns empty tuple otherwise
231
- # arguments SND_dir, vid and returned values are of Path type
232
- wav_files = list(SND_dir.iterdir())
233
- logger.debug(f'wav_files {wav_files} in {SND_dir}')
234
- def is_mix(p):
235
- re_result = re.match(r'mix([lrLR])*', p.name)
236
- logger.debug(f'for {p.name} re_result looking for mix {re_result}')
237
- return re_result is not None
238
- mix_files = [p for p in wav_files if is_mix(p)]
239
- if len(mix_files) == 0:
240
- return ()
241
- # consistency check, should be 1 or 2 files
242
- if not len(mix_files) in (1,2):
243
- print(f'\nError: too many mix files in [bold]{SND_dir}[/bold], bye.')
244
- sys.exit(0)
245
- # one file? it must be mix.wav
246
- if len(mix_files) == 1:
247
- fn = mix_files[0].name
248
- if fn.upper() != 'MIX.WAV':
249
- print(f'\nError in [bold]{SND_dir}[/bold], the only file should be mix.wav, not [bold]{fn}[/bold][/bold]; bye.')
250
- sys.exit(0)
251
- # two files? verify they are mixL and mixR and mono each
252
- if len(mix_files) == 2:
253
- first3uppercase = [p.name[:4].upper() for p in mix_files]
254
- first3uppercase.sort()
255
- first3uppercase = ''.join(first3uppercase)
256
- if first3uppercase != 'MIXLMIXR':
257
- print(f'\nError: mix names mismatch in [bold]{SND_dir}[/bold];')
258
- print(f'names are [bold]{[p.name for p in mix_files]}[/bold], check they are simply mixL.wav and mixR.wav; bye.')
259
- sys.exit(0)
260
- def _nch(p):
261
- return sox.file_info.channels(str(p))
262
- are_mono = [_nch(p) == 1 for p in mix_files]
263
- logger.debug('are_mono: %s'%are_mono)
264
- if not all(are_mono):
265
- print(f'\nError in [bold]{SND_dir}[/bold], some files are not mono, bye.')
266
- sys.exit(0)
267
- logger.debug(f'mix_files: {mix_files}')
268
- # check dates, if two files, take first
269
- mix_modification_time = mix_files[0].stat().st_mtime
270
- vid_mod_time = vid.stat().st_mtime
271
- # difference of modification time in secs
272
- mix_more_recent_by = mix_modification_time - vid_mod_time
273
- logger.debug('mix_more_recent_by: %s'%mix_more_recent_by)
274
- if mix_more_recent_by > SEC_DELAY_CHANGED_SND:
275
- if len(mix_files) == 1:
276
- two_folders_up = mix_files[0]
277
- # two_folders_up = Path('').joinpath(*mix_files[0].parts[-3:])
278
- print(f'\nFound new mix: [bold]{two_folders_up}[/bold]')
279
- return mix_files
280
- else:
281
- return ()
282
-
283
- def _keep_VIDEO_only(video_path):
284
- # return file handle to a temp video file formed from the video_path
285
- # stripped of its sound
286
- in1 = ffmpeg.input(_pathname(video_path))
287
- video_extension = video_path.suffix
288
- silenced_opts = ["-loglevel", "quiet", "-nostats", "-hide_banner"]
289
- file_handle = tempfile.NamedTemporaryFile(suffix=video_extension,
290
- delete=DEL_TEMP)
291
- out1 = in1.output(file_handle.name, map='0:v', vcodec='copy')
292
- ffmpeg.run([out1.global_args(*silenced_opts)], overwrite_output=True)
293
- return file_handle
294
-
295
- def _change_audio4video(audio_path: Path, video: Path):
296
- """
297
- Replace audio in video (argument) by the audio contained in
298
- audio_path (argument) returns nothing
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
303
-
304
- """
305
- vidname, video_ext = video.name.split('.')
306
- vid_only_handle = _keep_VIDEO_only(video)
307
- a_n = _pathname(audio_path)
308
- v_n = _pathname(vid_only_handle)
309
- # check v suffix
310
- m = re.match(f'(.*)v(\\d+)', vidname)
311
- if m == None:
312
- # no suffix, add one
313
- out_path = video.parent / f'{vidname}v2.{video_ext}'
314
- out_n = _pathname(out_path)
315
- # for Resolve search
316
- partial_filename = str(video.parent / vidname)
317
- else:
318
- base, number_str = m.groups()
319
- logger.debug(f'base {base}, number_str {number_str}')
320
- up_tick = 1 + int(number_str)
321
- out_path = video.parent / f'{base}v{up_tick}.{video_ext}'
322
- out_n = _pathname(out_path)
323
- # for Resolve search
324
- partial_filename = str(video.parent / base)
325
- print(f'Video [bold]{video}[/bold] \nhas new sound and is now [bold]{out_path}[/bold]')
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
330
- # building args for debug purpose only:
331
- ffmpeg_args = (
332
- ffmpeg
333
- .input(v_n)
334
- .output(out_n, vcodec='copy')
335
- # .output(out_n, shortest=None, vcodec='copy')
336
- .global_args('-i', a_n, "-hide_banner")
337
- .overwrite_output()
338
- .get_args()
339
- )
340
- logger.debug('ffmpeg args: %s'%' '.join(ffmpeg_args))
341
- try: # for real now
342
- _, out = (
343
- ffmpeg
344
- .input(v_n)
345
- # .output(out_n, shortest=None, vcodec='copy')
346
- .output(out_n, vcodec='copy')
347
- .global_args('-i', a_n, "-hide_banner")
348
- .overwrite_output()
349
- .run(capture_stderr=True)
350
- )
351
- logger.debug('ffmpeg output')
352
- for l in out.decode("utf-8").split('\n'):
353
- logger.debug(l)
354
- except ffmpeg.Error as e:
355
- print('ffmpeg.run error merging: \n\t %s + %s = %s\n'%(
356
- audio_path,
357
- video_path,
358
- synced_clip_file
359
- ))
360
- print(e)
361
- print(e.stderr.decode('UTF-8'))
362
- sys.exit(1)
363
- return partial_filename, new_file
364
-
365
- def _sox_combine(paths) -> Path:
366
- """
367
- Combines (stacks) files referred by the list of Path into a new temporary
368
- files passed on return each files are stacked in a different channel, so
369
- len(paths) == n_channels
370
- """
371
- if len(paths) == 1: # one device only, nothing to stack
372
- logger.debug('one device only, nothing to stack')
373
- return paths[0] ########################################################
374
- out_file_handle = tempfile.NamedTemporaryFile(suffix='.wav',
375
- delete=DEL_TEMP)
376
- filenames = [_pathname(p) for p in paths]
377
- out_file_name = _pathname(out_file_handle)
378
- logger.debug('combining files: %s into %s'%(
379
- filenames,
380
- out_file_name))
381
- cbn = sox.Combiner()
382
- cbn.set_input_format(file_type=['wav']*len(paths))
383
- status = cbn.build(
384
- filenames,
385
- out_file_name,
386
- combine_type='merge')
387
- logger.debug('sox.build status: %s'%status)
388
- if status != True:
389
- print('Error, sox did not merge files in _sox_combine()')
390
- sys.exit(1)
391
- merged_duration = sox.file_info.duration(
392
- _pathname(out_file_handle))
393
- nchan = sox.file_info.channels(
394
- _pathname(out_file_handle))
395
- logger.debug('merged file duration %f s with %i channels '%
396
- (merged_duration, nchan))
397
- return out_file_handle
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
422
-
423
- def main():
424
- # proxies_dir, originals_dir, audio_dir, unique_directory, scan_only = \
425
- # parse_and_check_arguments()
426
- args = parse_and_check_arguments()
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 = []
439
- for SND_dir, vid in matching_pairs:
440
- new_mix_files = get_recent_mix(SND_dir, vid)
441
- logger.debug('new_mix_files: %s'%str(new_mix_files))
442
- if new_mix_files != ():
443
- logger.debug(f'new mixes {new_mix_files} in {SND_dir} for {vid.name}')
444
- if len(new_mix_files) == 2:
445
- new_audio_wav = _sox_combine(new_mix_files)
446
- logger.debug('stereo_wav: %s'%new_audio_wav)
447
- else: # len == 1, mono wav file
448
- new_audio_wav = new_mix_files[0]
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)
480
-
481
-
482
- if __name__ == '__main__':
483
- main()