tictacsync 0.1a14__py3-none-any.whl → 1.4.4b0__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/mamdav.py ADDED
@@ -0,0 +1,642 @@
1
+ import os, itertools, argparse, ffmpeg, tempfile, platformdirs, pathlib
2
+ from enum import Enum
3
+ from pathlib import Path
4
+ import shutil, sys, re, sox, configparser
5
+ from pprint import pformat
6
+ from rich import print
7
+ import numpy as np
8
+ from scipy.io.wavfile import write as wrt_wav
9
+ import rich.progress, uuid
10
+
11
+ try:
12
+ from . import mamconf
13
+ from . import yaltc
14
+ from . import mamreap
15
+ except:
16
+ import mamconf
17
+ import yaltc
18
+ import mamreap
19
+
20
+ from loguru import logger
21
+
22
+ ONE_HR_START = 3600
23
+
24
+ LUA = True
25
+ OUT_DIR_DEFAULT = 'SyncedMedia'
26
+ MCCDIR = 'SyncedMulticamClips'
27
+ SEC_DELAY_CHANGED_SND = 1 #sec, SND_DIR changed if diff time is bigger [why not zero?]
28
+ DEL_TEMP = False
29
+ CONF_FILE = 'mamsync.cfg'
30
+
31
+ logger.remove()
32
+ # logger.add(sys.stdout, level="DEBUG")
33
+
34
+ # logger.level("DEBUG", color="<yellow>")
35
+ # logger.add(sys.stdout, filter=lambda r: r["function"] == "find_SND_vids_pairs_in_dual_dir")
36
+ # logger.add(sys.stdout, filter=lambda r: r["function"] == "read_OTIO_file")
37
+
38
+ v_file_extensions = \
39
+ """MOV webm mkv flv flv vob ogv ogg drc gif gifv mng avi MTS M2TS TS mov qt
40
+ wmv yuv rm rmvb viv asf amv mp4 m4p m4v mpg mp2 mpeg mpe mpv mpg mpeg m2v
41
+ m4v svi 3gp 3g2 mxf roq nsv flv f4v f4p f4a f4b 3gp""".split()
42
+
43
+
44
+ DAVINCI_RESOLVE_SCRIPT_LOCATION = '/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Utility/'
45
+
46
+ DAVINCI_RESOLVE_SCRIPT_TEMPLATE_LUA = """local function ClipWithPartialPath(partial_path)
47
+ local media_pool = app:GetResolve():GetProjectManager():GetCurrentProject():GetMediaPool()
48
+ local queue = {media_pool:GetRootFolder()}
49
+ while #queue > 0 do
50
+ local current = table.remove(queue, 1)
51
+ local subfolders = current:GetSubFolderList()
52
+ for _, folder in ipairs(subfolders) do
53
+ table.insert(queue, folder)
54
+ end
55
+ local got_it = {}
56
+ local clips = current:GetClipList()
57
+ for _, cl in ipairs(clips) do
58
+ if string.find(cl:GetClipProperty('File Path'), partial_path) then
59
+ table.insert(got_it, cl)
60
+ end
61
+ end
62
+ if #got_it > 0 then
63
+ return got_it[1]
64
+ end
65
+ end
66
+ end
67
+
68
+ local function findAndReplace(trio)
69
+ local old_file = trio[2]
70
+ local new_file = trio[3]
71
+ local name = trio[1]
72
+ local clip_with_old_file = ClipWithPartialPath(old_file)
73
+ if clip_with_old_file == nil then
74
+ print('did not find clip with path ' .. old_file)
75
+ else
76
+ clip_with_old_file:ReplaceClip(new_file)
77
+ local cfp = clip_with_old_file:GetClipProperty('File Path')
78
+ clip_with_old_file:SetClipProperty('Clip Name', name)
79
+ local cn = clip_with_old_file:GetClipProperty('Clip Name')
80
+ if cfp == new_file then
81
+ print('Loaded ' .. cn .. ' with a new sound track')
82
+ else
83
+ print('findAndReplace ' .. old_file .. ' -> ' .. new_file .. ' failed')
84
+ end
85
+ end
86
+ end
87
+
88
+ local changes = {
89
+ """
90
+
91
+ video_extensions = \
92
+ """webm mkv flv flv vob ogv ogg drc gif gifv mng avi mov
93
+ qt wmv yuv rm rmvb viv asf mp4 m4p m4v mpg mp2 mpeg mpe
94
+ mpv mpg mpeg m2v m4v svi 3gp 3g2 mxf roq nsv""".split() # from wikipedia
95
+
96
+ def rint(x):
97
+ return int(round(x))
98
+
99
+ # logger.add(sys.stdout, filter=lambda r: r["function"] == "_vid_stem")
100
+ def _vid_stem(video):
101
+ # from a video name (str or pathlib.Path)
102
+ # return the stem without any version letter
103
+ # e.g.: canon24fps01_vagygg8789732hj..32..65765.MOV -> canon24fps01
104
+ if not isinstance(video, pathlib.Path):
105
+ video = pathlib.Path(video)
106
+ if not '_v' in video.name:
107
+ return video.stem
108
+ m = re.match(r'(?P<stem>.+?)_v(\w{32})', video.name)
109
+ logger.debug(f're.match.groups: {m.groups()}')
110
+ if m == None:
111
+ print(f'Error trying to process name {video} ; Bye.')
112
+ sys.exit(0)
113
+ logger.debug(f'stem: {m.group("stem")} from {video}')
114
+ return m.group('stem')
115
+
116
+ class Modes(Enum):
117
+ INTRACLIP = 1 # send-to-pict <no args>
118
+ # scans SNDROOT and finds new mixes more recent than
119
+ # their video counterparts and merge them.
120
+
121
+ INTERCLIP_SOME = 2 # send-to-pict <otio_stem>
122
+ # looks into SNDROOT/Sound_Edits and find specified trio
123
+ # SoundForMyMovie/Sound_Edits/<otio_stem>_mix.wav
124
+ # SoundForMyMovie/Sound_Edits/<otio_stem>.otio
125
+ # SoundForMyMovie/Sound_Edits/<otio_stem>.mov <- with TC!
126
+
127
+ INTERCLIP_ALL = 3 # send-to-pict <otio_stem>
128
+ # looks into SNDROOT/Sound_Edits and find specified duo
129
+ # SoundForMyMovie/Sound_Edits/<otio_stem>_mix.wav
130
+ # SoundForMyMovie/Sound_Edits/<otio_stem>.otio
131
+
132
+ # logger.add(sys.stdout, filter=lambda r: r["function"] == "parse_args_for_mode_and_files")
133
+ def parse_args_for_mode_and_files():
134
+ """
135
+ Parses command arguments and determines mode and associated files.
136
+ Checks for inconsistencies and warn user and exits.
137
+
138
+ There are two major modes: intraclip or interclip.
139
+ intraclip: only a clip name is needed, even partial, e.g.: DSC_8064
140
+ interclip: an otio file and a wav mix is needed, opt. a rendered video.
141
+
142
+ Will look for three files in the same directory (interclip):
143
+ an otio file
144
+ a wav mix file
145
+ an optional video file
146
+ each file starts with the same prefix and the mix file should fit the
147
+ <pre>_mix.wav pattern, e.g.:
148
+ cut42.otio
149
+ cut42_mix.wav
150
+ cut42.mov
151
+
152
+ so one could call: mamdav /foo/edits/cut42*
153
+
154
+ Returns tuple of (mode, otio_path, movie_path, wav_path)
155
+ """
156
+ descr = "Create a DaVinci Resolve script to reload videos whose audio track has been modified."
157
+ parser = argparse.ArgumentParser(description=descr)
158
+ parser.add_argument(
159
+ nargs='*',
160
+ dest='otio_mix_files', # mamdav cut27*
161
+ help='name of the clip, or otio & wav files to be used')
162
+ parser.add_argument('-c',
163
+ action='store_true',
164
+ help="clear any version number from Resolve MediaPool and exit.")
165
+ args = parser.parse_args()
166
+ logger.debug('args %s args.otio_mix_files %s'%(args, args.otio_mix_files))
167
+ if args.c:
168
+ pass # [TODO] clear sunced_root or Resolve via otio?
169
+ if args.otio_mix_files == []:
170
+ return Modes.INTRACLIP, None, None, None ###############################
171
+ # in args.otio_mix_files, find which one is otio, which one is wav
172
+ # and maybe which one is a video, doing so, determine the mode
173
+ omfp = [pathlib.Path(f) for f in args.otio_mix_files]
174
+ otio = [p for p in omfp if p.suffix.lower() == '.otio']
175
+ if len(otio) != 1:
176
+ print(f'Error, problem finding otio file in {args.otio_mix_files}, bye.')
177
+ sys.exit(0)
178
+ otio_path = otio[0]
179
+ wav = [p for p in omfp if p.suffix.lower() == '.wav']
180
+ if len(wav) != 1:
181
+ print(f'Error, problem finding wav file in {args.otio_mix_files}, bye.')
182
+ sys.exit(0)
183
+ wav_path = wav[0]
184
+ # check cut42.otio -> cut42_mix.wav
185
+ # first, check _mix.wav?
186
+ if '_' not in str(wav_path.name) or str(wav_path.name).split('_')[1].lower() != 'mix.wav':
187
+ print(f'Error, {wav_path.name} doesnt contain "_mix.wav", bye.')
188
+ sys.exit(0)
189
+ # cut42* for both?
190
+ if otio_path.stem != str(wav_path.name).split('_')[0]:
191
+ print(f'Error, {otio_path} and {wav_path} dont have the same prefix, bye.')
192
+ sys.exit(0)
193
+ if len(omfp) == 2:
194
+ # [TODO] we're done, mode is Modes.INTERCLIP_ALL
195
+ # Modes.INTERCLIP_SOME will be implemented later...
196
+ mode = Modes.INTERCLIP_ALL
197
+ movie_path = None
198
+ logger.debug(f'mode, otio_path, movie_path, wav_path:')
199
+ logger.debug(f'{mode}, {otio_path}, {movie_path}, {wav_path}.')
200
+ return mode, otio_path, movie_path, wav_path ##########################
201
+ # if len(args.otio_mix_files), check 3rd is video
202
+ def _neither_otio_wav(p):
203
+ suf = p.suffix.lower()
204
+ return suf not in ['.otio', '.wav']
205
+ other = [p for p in omfp if _neither_otio_wav(p)]
206
+ movie_path = other[0]
207
+ if len(other) != 1 or movie_path.suffix[1:] not in v_file_extensions:
208
+ print(f'Error, cant find video file in {other}, bye.')
209
+ sys.exit(0)
210
+ mode = Modes.INTERCLIP_SOME
211
+ logger.debug(f'mode, otio_path, movie_path, wav_path:')
212
+ logger.debug(f'{mode}, {otio_path}, {movie_path}, {wav_path}.')
213
+ print('Sorry, INTERCLIP_SOME not yet implemented, bye')
214
+ sys.exit(0)
215
+ return mode, otio_path, movie_path, wav_path ##############################
216
+
217
+ def _pathname(tempfile_or_path) -> str:
218
+ # utility for obtaining a str from different filesystem objects
219
+ if isinstance(tempfile_or_path, str):
220
+ return tempfile_or_path
221
+ if isinstance(tempfile_or_path, Path):
222
+ return str(tempfile_or_path)
223
+ if isinstance(tempfile_or_path, tempfile._TemporaryFileWrapper):
224
+ return tempfile_or_path.name
225
+ else:
226
+ raise Exception('%s should be Path or tempfile...'%tempfile_or_path)
227
+
228
+ def is_synced_video(f):
229
+ # True if name as video extension
230
+ # and is under SyncedMedia or SyncedMulticamClips folders
231
+ # f is a Path
232
+ ext = f.suffix[1:] # removing leading '.'
233
+ ok_ext = ext.lower() in video_extensions
234
+ f_parts = f.parts
235
+ ok_folders = OUT_DIR_DEFAULT in f_parts or MCCDIR in f_parts
236
+ # logger.debug('ok_ext: %s ok_folders: %s'%(ok_ext, ok_folders))
237
+ return ok_ext and ok_folders
238
+
239
+ def _names_match(vidname, SND_name):
240
+ # vidname is a str and has no extension
241
+ # vidname could have v<uuid> suffix as in DSC_8064_v03e3f5d2bc3d11f0a8d038c9864d497d so matches DSC_8064
242
+ if vidname == SND_name: # no suffix presents
243
+ return True
244
+ # m = re.match(SND_name + r'v(\d+)', vidname)
245
+ m = re.match(SND_name + r'_v(\w{32})', vidname)
246
+ if m != None:
247
+ logger.debug('its a match and letter= %s for %s'%(m.groups()[0], vidname))
248
+ return m != None
249
+
250
+ def find_SND_vids_pairs_in_dual_dir(synced_root, snd_root):
251
+ # look for matching video stem (without _v*) and SND in the two argument directories
252
+ # eg: IMG04_v13ab..cde.mov and directory IMG04_SND
253
+ # returns dict of (key: vid stem; value: paths tuple), where vid is str and
254
+ # paths_tuple a tuple of found pathlib.Path (vid path, SND dir)
255
+ # recursively search from 'top' argument
256
+ vids = []
257
+ SNDs = []
258
+ print(f'Will look for new mix in {snd_root} compared to vids in {synced_root}')
259
+ for (root,dirs,files) in os.walk(synced_root):
260
+ for f in files:
261
+ pf = Path(root)/f
262
+ if pf.suffix[1:].lower() in video_extensions:
263
+ if not pf.is_symlink():
264
+ vids.append(pf)
265
+ for (root,dirs,files) in os.walk(snd_root):
266
+ for d in dirs:
267
+ if d[-4:] == '_SND':
268
+ SNDs.append(Path(root)/d)
269
+ logger.debug('vids %s SNDs %s'%(pformat(vids), pformat(SNDs)))
270
+ # check for name collision in vids
271
+ vid_stems_set = set([_vid_stem(f) for f in vids])
272
+ vid_stems = [_vid_stem(f) for f in vids]
273
+ if len(vid_stems_set) != len(vid_stems):
274
+ print('Error, there are name collision in clip names:')
275
+ vid_stems.sort()
276
+ for vs in vid_stems:
277
+ print(vs)
278
+ print('\nBye.')
279
+ sys.exit(0)
280
+ matches = {}
281
+ for pair in list(itertools.product(SNDs, vids)):
282
+ SND, vid = pair # Paths
283
+ vidname = vid.stem
284
+ if _names_match(vidname, SND.name[:-4]):
285
+ logger.debug('SND %s matches video %s'%(
286
+ Path('').joinpath(*SND.parts[-2:]),
287
+ Path('').joinpath(*vid.parts[-3:])))
288
+ # matches.append(pair) # list of Paths
289
+ matches[_vid_stem(vid)] = (vid, SND) #
290
+ logger.debug('matches: %s'%pformat(matches))
291
+ return matches
292
+
293
+ def get_recent_mix(SND_dir, vid):
294
+ # search for a mix file in SND_dir
295
+ # and return the mix pathib.Path if it is more recent than vid.
296
+ # returns empty tuple otherwise
297
+ # arguments SND_dir, vid and returned values are of Path type
298
+ wav_files = list(SND_dir.iterdir())
299
+ logger.debug(f'wav_files {wav_files} in {SND_dir}')
300
+ def is_mix(p):
301
+ re_result = re.match(r'.*mix.*', p.name)
302
+ logger.debug(f'for {p.name} re_result looking for mix {re_result}')
303
+ return re_result is not None
304
+ mix_files = [p for p in wav_files if is_mix(p)]
305
+ if len(mix_files) == 0:
306
+ return ()
307
+ if len(mix_files) != 1:
308
+ print(f'\nError: too many mix files in [bold]{SND_dir}[/bold], bye.')
309
+ [print(f) for f in mix_files]
310
+ sys.exit(0)
311
+ mix = mix_files[0]
312
+ logger.debug(f'mix: {mix}')
313
+ # check dates, if two files, take first
314
+ mix_modification_time = mix.stat().st_mtime
315
+ vid_mod_time = vid.stat().st_mtime
316
+ # difference of modification time in secs
317
+ mix_more_recent_by = mix_modification_time - vid_mod_time
318
+ logger.debug('mix_more_recent_by: %s'%mix_more_recent_by)
319
+ if mix_more_recent_by > SEC_DELAY_CHANGED_SND:
320
+ # if len(mix_files) == 1:
321
+ # two_folders_up = mix_files[0]
322
+ # two_folders_up = Path('').joinpath(*mix_files[0].parts[-3:])
323
+ # print(f'\nFound new mix: [bold]{mix}[/bold]')
324
+ return mix
325
+ else:
326
+ return None
327
+
328
+ def _keep_VIDEO_only(video_path):
329
+ # return file handle to a temp video file formed from the video_path
330
+ # stripped of its sound
331
+ in1 = ffmpeg.input(_pathname(video_path))
332
+ video_extension = video_path.suffix
333
+ silenced_opts = ["-loglevel", "quiet", "-nostats", "-hide_banner"]
334
+ file_handle = tempfile.NamedTemporaryFile(suffix=video_extension,
335
+ delete=DEL_TEMP)
336
+ out1 = in1.output(file_handle.name, map='0:v', vcodec='copy')
337
+ ffmpeg.run([out1.global_args(*silenced_opts)], overwrite_output=True)
338
+ return file_handle
339
+
340
+ # logger.add(sys.stdout, filter=lambda r: r["function"] == "_build_new_names")
341
+ def _build_new_names(video: Path):
342
+ """
343
+ If name has version letter, upticks it it (DSC_8064vB.MOV -> DSC_8064vC.MOV)
344
+ Returns string tuple of old and new video files.
345
+ Old file is partial (without version and suffix) e.g.: ../DSC_8064 so
346
+ Resolve Lua script can find it in its MediaPool;
347
+ new file is complete.
348
+ Returns str tuple: partial_filename and out_n
349
+ """
350
+ vidname, video_ext = video.name.split('.')
351
+ # check v suffix
352
+ m = re.match(r'(.*?)_v(\w{32})', vidname)
353
+ logger.debug(f' for {vidname}, regex match {m}')
354
+ ID = uuid.uuid1().hex
355
+ if m == None:
356
+ logger.debug('no suffix, add one {ID}')
357
+ out_path = video.parent / f'{vidname}_v{ID}.{video_ext}'
358
+ out_n = _pathname(out_path)
359
+ # for Resolve search
360
+ partial_filename = str(video.parent / vidname) # vidname has no 'v' here
361
+ else:
362
+ base, old_uuid = m.groups()
363
+ logger.debug(f'base {base}, old_uuid {old_uuid}')
364
+ # new_letter = chr((ord(letter)+1 - 65) % 26 + 65) # next one
365
+ # logger.debug(f'new_letter {new_letter}')
366
+ out_path = video.parent / f'{base}_v{ID}.{video_ext}'
367
+ out_n = _pathname(out_path)
368
+ # for Resolve search
369
+ partial_filename = str(video.parent / base)
370
+ logger.debug(f'new version {out_n}')
371
+ return partial_filename, out_n
372
+
373
+ # logger.add(sys.stdout, filter=lambda r: r["function"] == "_change_audio4video")
374
+ def _change_audio4video(audio_path: Path, video: Path):
375
+ """
376
+ Replaces audio in video (argument) by the audio contained in
377
+ audio_path (argument)
378
+ Returns str tuple (partial_filename, new_file) see _build_new_names
379
+ """
380
+ partial_filename, new_file = _build_new_names(video)
381
+ # print(f'Video [bold]{video}[/bold] \nhas new sound and is now [bold]{new_file}[/bold]')
382
+ logger.debug(f'partial filname {partial_filename}')
383
+ new_audio = _pathname(audio_path)
384
+ vid_only_handle = _keep_VIDEO_only(video)
385
+ v_n = _pathname(vid_only_handle)
386
+ # new_file = str(out_path)
387
+ video.unlink() # remove old one
388
+ # building args for debug purpose only:
389
+ ffmpeg_args = (
390
+ ffmpeg
391
+ .input(v_n)
392
+ .output(new_file, vcodec='copy', acodec='pcm_s16le')
393
+ # .output(new_file, shortest=None, vcodec='copy')
394
+ .global_args('-i', new_audio, "-hide_banner")
395
+ .overwrite_output()
396
+ .get_args()
397
+ )
398
+ logger.debug('ffmpeg args: %s'%' '.join(ffmpeg_args))
399
+ try: # for real now
400
+ _, out = (
401
+ ffmpeg
402
+ .input(v_n)
403
+ # .output(new_file, shortest=None, vcodec='copy')
404
+ .output(new_file, vcodec='copy', acodec='pcm_s16le')
405
+ .global_args('-i', new_audio, "-hide_banner")
406
+ .overwrite_output()
407
+ .run(capture_stderr=True)
408
+ )
409
+ logger.debug('ffmpeg output')
410
+ for l in out.decode("utf-8").split('\n'):
411
+ logger.debug(l)
412
+ except ffmpeg.Error as e:
413
+ print('ffmpeg.run error merging: \n\t %s + %s = %s\n'%(
414
+ audio_path,
415
+ video_path,
416
+ synced_clip_file
417
+ ))
418
+ print(e)
419
+ print(e.stderr.decode('UTF-8'))
420
+ sys.exit(1)
421
+ return partial_filename, new_file
422
+
423
+ def _sox_combine(paths) -> Path:
424
+ """
425
+ Combines (stacks) files referred by the list of Path into a new temporary
426
+ files passed on return each files are stacked in a different channel, so
427
+ len(paths) == n_channels
428
+ """
429
+ if len(paths) == 1: # one device only, nothing to stack
430
+ logger.debug('one device only, nothing to stack')
431
+ return paths[0] ########################################################
432
+ out_file_handle = tempfile.NamedTemporaryFile(suffix='.wav',
433
+ delete=DEL_TEMP)
434
+ filenames = [_pathname(p) for p in paths]
435
+ out_file_name = _pathname(out_file_handle)
436
+ logger.debug('combining files: %s into %s'%(
437
+ filenames,
438
+ out_file_name))
439
+ cbn = sox.Combiner()
440
+ cbn.set_input_format(file_type=['wav']*len(paths))
441
+ status = cbn.build(
442
+ filenames,
443
+ out_file_name,
444
+ combine_type='merge')
445
+ logger.debug('sox.build status: %s'%status)
446
+ if status != True:
447
+ print('Error, sox did not merge files in _sox_combine()')
448
+ sys.exit(1)
449
+ merged_duration = sox.file_info.duration(
450
+ _pathname(out_file_handle))
451
+ nchan = sox.file_info.channels(
452
+ _pathname(out_file_handle))
453
+ logger.debug('merged file duration %f s with %i channels '%
454
+ (merged_duration, nchan))
455
+ return out_file_handle
456
+
457
+ def load_New_Sound_lua_script(new_mixes):
458
+ if LUA:
459
+ script = DAVINCI_RESOLVE_SCRIPT_TEMPLATE_LUA
460
+ postlude = ''
461
+ for a,b in new_mixes:
462
+ clip_name = Path(a).name
463
+ postlude += '{"%s", "%s","%s"},\n'%(clip_name, a, b)
464
+ # postlude += f"('{a}','{b}'),\n"
465
+ postlude += '}\n\nfor _, trio in ipairs(changes) do\n'
466
+ postlude += ' findAndReplace(trio)\n'
467
+ postlude += 'end\n'
468
+ # postlude += 'os.remove(pair[1])\n'
469
+ else: # python
470
+ script = DAVINCI_RESOLVE_SCRIPT_TEMPLATE
471
+ postlude = '\nchanges = [\n'
472
+ for a,b in new_mixes:
473
+ postlude += '("%s","%s"),\n'%(a,b)
474
+ postlude += ']\n'
475
+ postlude += '[findAndReplace(a,b) for a, b in changes]\n\n'
476
+ # foo = '[findAndReplace(a,b) for a, b in changes\n'
477
+ # print('foo',foo)
478
+ # print(postlude + foo)
479
+ return script + postlude
480
+
481
+ def merge_new_mixes_if_any(SND_for_vid):
482
+ # SND_for_vid is a dict of key: vid_stem val: (vid_path, SND_dir)
483
+ changes = []
484
+ new_mixes = []
485
+ # two loops for meaningfull progress bar
486
+ for vid_stem, pair in SND_for_vid.items() :
487
+ vid_path, SND_dir = pair
488
+ mix = get_recent_mix(SND_dir, vid_path)
489
+ logger.debug('mix: %s'%str(mix))
490
+ if mix != None:
491
+ new_mixes.append((mix, vid_path))
492
+ for mix, vid_path in rich.progress.track(new_mixes, description='merging new audio...'):
493
+ logger.debug(f'new mix {mix} for {vid_path.name}')
494
+ old_file, new_file = _change_audio4video(mix, vid_path)
495
+ changes.append((old_file, new_file))
496
+ return changes
497
+
498
+ # logger.add(sys.stdout, filter=lambda r: r["function"] == "slice_wav_for_clips")
499
+ def slice_wav_for_clips(wav_file, clips, fps):
500
+ # return sliced audio
501
+ N_channels = sox.file_info.channels(wav_file)
502
+ logger.debug(f'{wav_file} has {N_channels} channels')
503
+ tracks = yaltc.read_audio_data_from_file(wav_file, N_channels)
504
+ audio_data = tracks.T # interleave channel samples for later slicing
505
+ logger.debug(f'audio data shape {audio_data.shape}')
506
+ logger.debug(f'data: {tracks}')
507
+ logger.debug(f'tracks shape {tracks.shape}')
508
+ # timeline_pos_fr, absolute, ie first frame of whole project is 0
509
+ # in frame number
510
+ timeline_pos_fr = [int(round((cl.timeline_pos - ONE_HR_START)*fps))
511
+ for cl in clips]
512
+ logger.debug(f'timeline_pos_fr {timeline_pos_fr}')
513
+ # left_trims in frame units
514
+ left_trims = [rint((cl.in_time - cl.start_time)*fps) for cl in clips]
515
+ logger.debug(f'left_trims: {left_trims} frames')
516
+ # sampling frequency, samples per second
517
+ samples_per_second = sox.file_info.sample_rate(wav_file)
518
+ # number of audio samples per frames,
519
+ samples_per_frame = samples_per_second/fps
520
+ logger.debug(f'there are {samples_per_frame} audio samples for each frame')
521
+ # in counts of audio samples, As are starts of audio_data slices,
522
+ # Bs are end of slices (sample # excluded)
523
+ As = [rint((tmlp - Ltr)*samples_per_frame) for tmlp, Ltr
524
+ in zip(timeline_pos_fr, left_trims)]
525
+ Bs = [rint((cl.whole_duration*samples_per_second + A)) for cl, A
526
+ in zip(clips, As)]
527
+ logger.debug(f'As: {As} Bs: {Bs}')
528
+ ABs = list(zip(As,Bs))
529
+ logger.debug(f'ABs {ABs}, audio samples')
530
+ audio_slices = [audio_data[A:B] for A, B in zip(As, Bs)]
531
+ logger.debug(f'audio_slices lengths {[len(s) for s in audio_slices]}')
532
+ if len(audio_slices[0]) == 0:
533
+ logger.debug(f'first slice had negative start, must pad it')
534
+ n_null_samples = rint(left_trims[0]*samples_per_frame)*N_channels
535
+ silence = np.zeros(n_null_samples).reshape(-1, N_channels)
536
+ logger.debug(f'silence: {silence}')
537
+ oldA, oldB = ABs[0]
538
+ newA = oldA + len(silence) # should be 0 :-)
539
+ newB = oldB + len(silence)
540
+ logger.debug(f'newA, newB: {newA, newB}')
541
+ padded_audio_data = np.concatenate([silence, audio_data])
542
+ first_slice = padded_audio_data[newA:newB]
543
+ audio_slices = [first_slice] + audio_slices[1:]
544
+ # check if last clip has right trim, if so, should zero-pad the slice
545
+ lcl = clips[-1]
546
+ w, i, s, c = lcl.whole_duration, lcl.in_time, lcl.start_time, lcl.cut_duration
547
+ right_trim_last = w - i + s - c
548
+ logger.debug(f'right_trim_last: {right_trim_last} sec')
549
+ if right_trim_last > 0:
550
+ # add zeros to last slice
551
+ n_null_samples = rint(right_trim_last*samples_per_second)*N_channels
552
+ silence = np.zeros(n_null_samples).reshape(-1, N_channels)
553
+ new_last_slice = np.concatenate([audio_slices[-1], silence])
554
+ audio_slices = audio_slices[:-1] + [new_last_slice]
555
+ slices_durations = [rint(len(aslice)/samples_per_frame)
556
+ for aslice in audio_slices]
557
+ logger.debug(f'slices_durations {slices_durations}')
558
+ clip_durations = [rint(cl.whole_duration*fps) for cl in clips]
559
+ logger.debug(f'clip_durations: {clip_durations}')
560
+ same = [a==b for a,b in list(zip(slices_durations, clip_durations))]
561
+ logger.debug(f'same: {same}')
562
+ ok = all(same)
563
+ if not ok:
564
+ for clip, slice_duration in zip(clips, slices_durations):
565
+ print(f'clip "{clip.name}", duration: {rint(clip.whole_duration*fps)} slice duration: {slice_duration} frames')
566
+ raise Exception("Error: audio slices don't have the same duration than video clips. Bye.")
567
+ return audio_slices, samples_per_second
568
+
569
+ # logger.add(sys.stdout, filter=lambda r: r["function"] == "go")
570
+ def go(mode, otio_path, movie_path, wav_path):
571
+ raw_root, synced_root, snd_root, proxies = mamconf.get_proj(False)
572
+ match mode:
573
+ case Modes.INTRACLIP:
574
+ print(f'Intraclip sound editing: will search for new mixes in {snd_root}')
575
+ case Modes.INTERCLIP_ALL:
576
+ print(f'Interclip sound editing: will split whole soundtrack {wav_path} among all clips.')
577
+ case Modes.INTERCLIP_SOME:
578
+ print(f'Interclip sound editing: will split soundtrack extarct {wav_path} among some clips.')
579
+ logger.debug(f'mode is {mode}')
580
+ proj_name = Path(raw_root).name
581
+ synced_project = Path(synced_root)/proj_name
582
+ project_sounds = Path(snd_root)/proj_name
583
+ # SND_for_vid is a dict of (key: vid; value: (vpath, SND))
584
+ # where vid is video stem without version and SND is absolute pathlib.Path
585
+ # e.g.:
586
+ # vid = 'canon24fps01'
587
+ # vpath = '/Users/.../canon24fps01vB.mov' complete vid path
588
+ # SND = '/Users/.../canon24fps01_SND' <- directory where mix is could be saved
589
+ SND_for_vid = find_SND_vids_pairs_in_dual_dir(synced_project, project_sounds)
590
+ logger.debug(f'SND_for_vid {pformat(SND_for_vid)}')
591
+ if mode == Modes.INTERCLIP_ALL:
592
+ fps, clips =mamreap.read_OTIO_file(otio_path)
593
+ wav_length = sox.file_info.duration(wav_path)
594
+ last_clip = clips[-1]
595
+ otio_duration = last_clip.timeline_pos + last_clip.cut_duration - 3600
596
+ if not np.isclose(wav_length, otio_duration):
597
+ print(f'Error, mix wav file duration {wav_length} does not match timeline duration {otio_duration} seconds. Bye')
598
+ print(wav_path)
599
+ sys.exit(0)
600
+ logger.debug(f' mode is INTERCLIP_ALL, otio has {fps} fps')
601
+ audio_slices, samples_per_second = slice_wav_for_clips(wav_path, clips, fps)
602
+ slices_clips = zip(audio_slices, clips)
603
+ # breakpoint()
604
+ for aslc, clip in slices_clips:
605
+ stem = _vid_stem(clip.path)
606
+ wav_name = f'{stem}_mix.wav'
607
+ _, directory = SND_for_vid[stem]
608
+ wav_name = directory / wav_name
609
+ wrt_wav(wav_name, int(samples_per_second), aslc.astype(np.int16))
610
+ # logger.debug(f'audio_slice for clip {clip.name}: {aslc.shape} {aslc}')
611
+ logger.debug(f'written in {wav_name}')
612
+ # Modes.INTERCLIP_ALL check if length wav == length otio
613
+ changes = merge_new_mixes_if_any(SND_for_vid)
614
+ # breakpoint()
615
+ if len(changes) == 0:
616
+ print('No new mix.')
617
+ sys.exit(0)
618
+ else:
619
+ print('Here are the clips with new sound track: ', end='')
620
+ for old_file, new_file in changes[:-1]:
621
+ print(f'"{_vid_stem(new_file)}", ', end='')
622
+ old_file, new_file = changes[-1]
623
+ print(f'"{_vid_stem(new_file)}".')
624
+ logger.debug(f'changes {pformat(changes)}')
625
+ script = load_New_Sound_lua_script(changes)
626
+ script_path = Path(DAVINCI_RESOLVE_SCRIPT_LOCATION)/'Load New Sound.lua'
627
+ # script += f'os.remove("{script_path}")\n' # doesnt work
628
+ with open(script_path, 'w') as fh:
629
+ fh.write(script)
630
+ print(f'Wrote new Lua script: run it in Resolve under Workspace/Scripts/{script_path.stem};')
631
+ print(f'script full path: "{script_path}".')
632
+ # print(script)
633
+
634
+ # logger.add(sys.stdout, filter=lambda r: r["function"] == "called_from_cli")
635
+ def called_from_cli():
636
+ # MAM mode always
637
+ logger.debug('CLI')
638
+ mode, otio_path, movie_path, wav_path = parse_args_for_mode_and_files()
639
+ go(mode, otio_path, movie_path, wav_path)
640
+
641
+ if __name__ == '__main__':
642
+ called_from_cli()