tictacsync 0.98a0__py3-none-any.whl → 1.4.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 +62 -268
- tictacsync/entry.py +57 -166
- tictacsync/load_fieldr_reaper.py +352 -0
- tictacsync/mamconf.py +157 -0
- tictacsync/mamdav.py +642 -0
- tictacsync/mamreap.py +481 -0
- tictacsync/mamsync.py +343 -0
- tictacsync/multi2polywav.py +4 -3
- tictacsync/new-sound-resolve.py +469 -0
- tictacsync/newmix.py +483 -0
- tictacsync/remrgmx.py +6 -10
- tictacsync/splitmix.py +87 -0
- tictacsync/timeline.py +154 -98
- tictacsync/yaltc.py +359 -31
- {tictacsync-0.98a0.dist-info → tictacsync-1.4.0b0.dist-info}/METADATA +5 -6
- tictacsync-1.4.0b0.dist-info/RECORD +24 -0
- tictacsync-1.4.0b0.dist-info/entry_points.txt +7 -0
- tictacsync-0.98a0.dist-info/RECORD +0 -16
- tictacsync-0.98a0.dist-info/entry_points.txt +0 -4
- {tictacsync-0.98a0.dist-info → tictacsync-1.4.0b0.dist-info}/LICENSE +0 -0
- {tictacsync-0.98a0.dist-info → tictacsync-1.4.0b0.dist-info}/WHEEL +0 -0
- {tictacsync-0.98a0.dist-info → tictacsync-1.4.0b0.dist-info}/top_level.txt +0 -0
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()
|