tictacsync 1.2.0b0__py3-none-any.whl → 1.3.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 +21 -243
- tictacsync/entry.py +14 -10
- tictacsync/load_fieldr_reaper.py +352 -0
- tictacsync/mamconf.py +15 -35
- tictacsync/mamsync.py +8 -70
- tictacsync/multi2polywav.py +0 -1
- tictacsync/new-sound-resolve.py +469 -0
- tictacsync/splitmix.py +87 -0
- tictacsync/timeline.py +28 -26
- tictacsync/yaltc.py +357 -29
- {tictacsync-1.2.0b0.dist-info → tictacsync-1.3.0b0.dist-info}/METADATA +1 -1
- tictacsync-1.3.0b0.dist-info/RECORD +22 -0
- {tictacsync-1.2.0b0.dist-info → tictacsync-1.3.0b0.dist-info}/entry_points.txt +2 -1
- tictacsync-1.2.0b0.dist-info/RECORD +0 -19
- {tictacsync-1.2.0b0.dist-info → tictacsync-1.3.0b0.dist-info}/LICENSE +0 -0
- {tictacsync-1.2.0b0.dist-info → tictacsync-1.3.0b0.dist-info}/WHEEL +0 -0
- {tictacsync-1.2.0b0.dist-info → tictacsync-1.3.0b0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,469 @@
|
|
|
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"] == "_names_match")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DAVINCI_RESOLVE_SCRIPT_LOCATION = '/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Utility/'
|
|
27
|
+
|
|
28
|
+
DAVINCI_RESOLVE_SCRIPT_TEMPLATE_LUA = """local function ClipWithPartialPath(partial_path)
|
|
29
|
+
local media_pool = app:GetResolve():GetProjectManager():GetCurrentProject():GetMediaPool()
|
|
30
|
+
local queue = {media_pool:GetRootFolder()}
|
|
31
|
+
while #queue > 0 do
|
|
32
|
+
local current = table.remove(queue, 1)
|
|
33
|
+
local subfolders = current:GetSubFolderList()
|
|
34
|
+
for _, folder in ipairs(subfolders) do
|
|
35
|
+
table.insert(queue, folder)
|
|
36
|
+
end
|
|
37
|
+
local got_it = {}
|
|
38
|
+
local clips = current:GetClipList()
|
|
39
|
+
for _, cl in ipairs(clips) do
|
|
40
|
+
if string.find(cl:GetClipProperty('File Path'), partial_path) then
|
|
41
|
+
table.insert(got_it, cl)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
if #got_it > 0 then
|
|
45
|
+
return got_it[1]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
local function findAndReplace(old_file, new_file)
|
|
51
|
+
local clip_with_old_file = ClipWithPartialPath(old_file)
|
|
52
|
+
if clip_with_old_file == nil then
|
|
53
|
+
print('did not find clip with path ' .. old_file)
|
|
54
|
+
else
|
|
55
|
+
clip_with_old_file:ReplaceClip(new_file)
|
|
56
|
+
local cfp = clip_with_old_file:GetClipProperty('File Path')
|
|
57
|
+
local cn = clip_with_old_file:GetClipProperty('Clip Name')
|
|
58
|
+
if cfp == new_file then
|
|
59
|
+
print('Loaded ' .. cn .. ' with a new sound track')
|
|
60
|
+
else
|
|
61
|
+
print('findAndReplace ' .. old_file .. ' -> ' .. new_file .. ' failed')
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
local changes = {
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
video_extensions = \
|
|
70
|
+
"""webm mkv flv flv vob ogv ogg drc gif gifv mng avi mov
|
|
71
|
+
qt wmv yuv rm rmvb viv asf mp4 m4p m4v mpg mp2 mpeg mpe
|
|
72
|
+
mpv mpg mpeg m2v m4v svi 3gp 3g2 mxf roq nsv""".split() # from wikipedia
|
|
73
|
+
|
|
74
|
+
def _pathname(tempfile_or_path) -> str:
|
|
75
|
+
# utility for obtaining a str from different filesystem objects
|
|
76
|
+
if isinstance(tempfile_or_path, str):
|
|
77
|
+
return tempfile_or_path
|
|
78
|
+
if isinstance(tempfile_or_path, Path):
|
|
79
|
+
return str(tempfile_or_path)
|
|
80
|
+
if isinstance(tempfile_or_path, tempfile._TemporaryFileWrapper):
|
|
81
|
+
return tempfile_or_path.name
|
|
82
|
+
else:
|
|
83
|
+
raise Exception('%s should be Path or tempfile...'%tempfile_or_path)
|
|
84
|
+
|
|
85
|
+
def is_synced_video(f):
|
|
86
|
+
# True if name as video extension
|
|
87
|
+
# and is under SyncedMedia or SyncedMulticamClips folders
|
|
88
|
+
# f is a Path
|
|
89
|
+
ext = f.suffix[1:] # removing leading '.'
|
|
90
|
+
ok_ext = ext.lower() in video_extensions
|
|
91
|
+
f_parts = f.parts
|
|
92
|
+
ok_folders = OUT_DIR_DEFAULT in f_parts or MCCDIR in f_parts
|
|
93
|
+
# logger.debug('ok_ext: %s ok_folders: %s'%(ok_ext, ok_folders))
|
|
94
|
+
return ok_ext and ok_folders
|
|
95
|
+
|
|
96
|
+
def _names_match(vidname, SND_name):
|
|
97
|
+
# vidname is a str and has no extension
|
|
98
|
+
# vidname could have vA or vB suffix as in DSC_8064vB so matches DSC_8064
|
|
99
|
+
# names flips between A and B for Resolve to reload them
|
|
100
|
+
if vidname == SND_name: # no suffix presents
|
|
101
|
+
return True
|
|
102
|
+
# m = re.match(SND_name + r'v(\d+)', vidname)
|
|
103
|
+
m = re.match(SND_name + r'v*([AB]*)', vidname)
|
|
104
|
+
if m != None:
|
|
105
|
+
logger.debug('its a match and letter= %s'%m.groups()[0])
|
|
106
|
+
return m != None
|
|
107
|
+
|
|
108
|
+
def find_SND_vids_pairs_in_udir(top):
|
|
109
|
+
# look for matching video name and SND in unique directory
|
|
110
|
+
# in alongside mode (vs MAM mode)
|
|
111
|
+
# eg: IMG04.mp4 and IMG04_SND
|
|
112
|
+
# maybe IMG04v2.mp4 if audio changed before (than it will be IMG04v3.mp4)
|
|
113
|
+
# returns list of (SND, vid) matches
|
|
114
|
+
# recursively search from 'top' argument
|
|
115
|
+
vids = []
|
|
116
|
+
SNDs = []
|
|
117
|
+
for (root,dirs,files) in os.walk(top):
|
|
118
|
+
for d in dirs:
|
|
119
|
+
if d[-4:] == '_SND':
|
|
120
|
+
SNDs.append(Path(root)/d)
|
|
121
|
+
for f in files:
|
|
122
|
+
if is_synced_video(Path(root)/f): # add being in SyncedMedia or SyncedMulticamClips folder
|
|
123
|
+
vids.append(Path(root)/f)
|
|
124
|
+
logger.debug('vids %s SNDs %s'%(pformat(vids), pformat(SNDs)))
|
|
125
|
+
matches = []
|
|
126
|
+
for pair in list(itertools.product(SNDs, vids)):
|
|
127
|
+
# print(pair)
|
|
128
|
+
SND, vid = pair # Paths
|
|
129
|
+
vidname, ext = vid.name.split('.') # string
|
|
130
|
+
if _names_match(vidname, SND.name[:-4]):
|
|
131
|
+
logger.debug('SND %s matches video %s'%(
|
|
132
|
+
Path('').joinpath(*SND.parts[-2:]),
|
|
133
|
+
Path('').joinpath(*vid.parts[-3:])))
|
|
134
|
+
matches.append(pair) # list of Paths
|
|
135
|
+
logger.debug('matches: %s'%pformat(matches))
|
|
136
|
+
return matches
|
|
137
|
+
|
|
138
|
+
def find_SND_vids_pairs_in_dual_dir(synced_root, snd_root):
|
|
139
|
+
# look for matching video name and SND in unique directory
|
|
140
|
+
# in alongside mode (vs MAM mode)
|
|
141
|
+
# eg: IMG04.mov and directory IMG04_SND
|
|
142
|
+
# maybe IMG04vA.mov if audio changed before (than it will be IMG04vB.mov)
|
|
143
|
+
# returns list of (SND, vid) matches
|
|
144
|
+
# recursively search from 'top' argument
|
|
145
|
+
vids = []
|
|
146
|
+
SNDs = []
|
|
147
|
+
print(f'Will look for new mix in {snd_root} compared to vids in {synced_root}')
|
|
148
|
+
for (root,dirs,files) in os.walk(synced_root):
|
|
149
|
+
for f in files:
|
|
150
|
+
pf = Path(root)/f
|
|
151
|
+
if pf.suffix[1:].lower() in video_extensions:
|
|
152
|
+
if not pf.is_symlink():
|
|
153
|
+
vids.append(pf)
|
|
154
|
+
for (root,dirs,files) in os.walk(snd_root):
|
|
155
|
+
for d in dirs:
|
|
156
|
+
if d[-4:] == '_SND':
|
|
157
|
+
SNDs.append(Path(root)/d)
|
|
158
|
+
logger.debug('vids %s SNDs %s'%(pformat(vids), pformat(SNDs)))
|
|
159
|
+
matches = []
|
|
160
|
+
# def _names_match(vidname, SND_name):
|
|
161
|
+
# # vidname is a str and has no extension
|
|
162
|
+
# # vidname could have vNN suffix as in DSC_8064v31 so matches DSC_8064
|
|
163
|
+
# if vidname == SND_name: # no suffix presents
|
|
164
|
+
# return True
|
|
165
|
+
# m = re.match(SND_name + r'v(\d+)', vidname)
|
|
166
|
+
# if m != None:
|
|
167
|
+
# logger.debug('its a natch and N= %s'%m.groups()[0])
|
|
168
|
+
# return m != None
|
|
169
|
+
for pair in list(itertools.product(SNDs, vids)):
|
|
170
|
+
SND, vid = pair # Paths
|
|
171
|
+
vidname = vid.stem
|
|
172
|
+
if _names_match(vidname, SND.name[:-4]):
|
|
173
|
+
logger.debug('SND %s matches video %s'%(
|
|
174
|
+
Path('').joinpath(*SND.parts[-2:]),
|
|
175
|
+
Path('').joinpath(*vid.parts[-3:])))
|
|
176
|
+
matches.append(pair) # list of Paths
|
|
177
|
+
logger.debug('matches: %s'%pformat(matches))
|
|
178
|
+
return matches
|
|
179
|
+
|
|
180
|
+
def parse_and_check_arguments():
|
|
181
|
+
# parses directories from command arguments
|
|
182
|
+
# check for consistencies and warn user and exits,
|
|
183
|
+
# returns parser.parse_args()
|
|
184
|
+
|
|
185
|
+
descr = "Checks in SNDROOT (see mamconf --show) if any new mix is present; if so, build a Davinci Resolve script to reload corresponding videos."
|
|
186
|
+
parser = argparse.ArgumentParser(description=descr)
|
|
187
|
+
parser.add_argument('-u',
|
|
188
|
+
nargs=1,
|
|
189
|
+
dest='unique_directory',
|
|
190
|
+
help='Directory scanned for both audio and video, when tictacsync is used rather than mamsync.')
|
|
191
|
+
parser.add_argument('--dry',
|
|
192
|
+
action='store_true',
|
|
193
|
+
dest='scan_only',
|
|
194
|
+
help="Just display changed audio, don't merge")
|
|
195
|
+
args = parser.parse_args()
|
|
196
|
+
logger.debug('args %s'%args)
|
|
197
|
+
if args.scan_only:
|
|
198
|
+
print('Sorry, --dry option not implemented yet, bye.')
|
|
199
|
+
sys.exit(0)
|
|
200
|
+
return args
|
|
201
|
+
|
|
202
|
+
def get_recent_mix(SND_dir, vid):
|
|
203
|
+
# check if there are mixl, mixr or mix files in SND_dir
|
|
204
|
+
# and return the paths if they are more recent than vid.
|
|
205
|
+
# returns empty tuple otherwise
|
|
206
|
+
# arguments SND_dir, vid and returned values are of Path type
|
|
207
|
+
wav_files = list(SND_dir.iterdir())
|
|
208
|
+
logger.debug(f'wav_files {wav_files} in {SND_dir}')
|
|
209
|
+
def is_mix(p):
|
|
210
|
+
re_result = re.match(r'mix([lrLR])*', p.name)
|
|
211
|
+
logger.debug(f'for {p.name} re_result looking for mix {re_result}')
|
|
212
|
+
return re_result is not None
|
|
213
|
+
mix_files = [p for p in wav_files if is_mix(p)]
|
|
214
|
+
if len(mix_files) == 0:
|
|
215
|
+
return ()
|
|
216
|
+
# consistency check, should be 1 or 2 files
|
|
217
|
+
if not len(mix_files) in (1,2):
|
|
218
|
+
print(f'\nError: too many mix files in [bold]{SND_dir}[/bold], bye.')
|
|
219
|
+
sys.exit(0)
|
|
220
|
+
# one file? it must be mix.wav
|
|
221
|
+
if len(mix_files) == 1:
|
|
222
|
+
fn = mix_files[0].name
|
|
223
|
+
if fn.upper() != 'MIX.WAV':
|
|
224
|
+
print(f'\nError in [bold]{SND_dir}[/bold], the only file should be mix.wav, not [bold]{fn}[/bold][/bold]; bye.')
|
|
225
|
+
sys.exit(0)
|
|
226
|
+
# two files? verify they are mixL and mixR and mono each
|
|
227
|
+
if len(mix_files) == 2:
|
|
228
|
+
first3uppercase = [p.name[:4].upper() for p in mix_files]
|
|
229
|
+
first3uppercase.sort()
|
|
230
|
+
first3uppercase = ''.join(first3uppercase)
|
|
231
|
+
if first3uppercase != 'MIXLMIXR':
|
|
232
|
+
print(f'\nError: mix names mismatch in [bold]{SND_dir}[/bold];')
|
|
233
|
+
print(f'names are [bold]{[p.name for p in mix_files]}[/bold], check they are simply mixL.wav and mixR.wav; bye.')
|
|
234
|
+
sys.exit(0)
|
|
235
|
+
def _nch(p):
|
|
236
|
+
return sox.file_info.channels(str(p))
|
|
237
|
+
are_mono = [_nch(p) == 1 for p in mix_files]
|
|
238
|
+
logger.debug('are_mono: %s'%are_mono)
|
|
239
|
+
if not all(are_mono):
|
|
240
|
+
print(f'\nError in [bold]{SND_dir}[/bold], some files are not mono, bye.')
|
|
241
|
+
sys.exit(0)
|
|
242
|
+
logger.debug(f'mix_files: {mix_files}')
|
|
243
|
+
# check dates, if two files, take first
|
|
244
|
+
mix_modification_time = mix_files[0].stat().st_mtime
|
|
245
|
+
vid_mod_time = vid.stat().st_mtime
|
|
246
|
+
# difference of modification time in secs
|
|
247
|
+
mix_more_recent_by = mix_modification_time - vid_mod_time
|
|
248
|
+
logger.debug('mix_more_recent_by: %s'%mix_more_recent_by)
|
|
249
|
+
if mix_more_recent_by > SEC_DELAY_CHANGED_SND:
|
|
250
|
+
if len(mix_files) == 1:
|
|
251
|
+
two_folders_up = mix_files[0]
|
|
252
|
+
# two_folders_up = Path('').joinpath(*mix_files[0].parts[-3:])
|
|
253
|
+
print(f'\nFound new mix: [bold]{two_folders_up}[/bold]')
|
|
254
|
+
return mix_files
|
|
255
|
+
else:
|
|
256
|
+
return ()
|
|
257
|
+
|
|
258
|
+
def _keep_VIDEO_only(video_path):
|
|
259
|
+
# return file handle to a temp video file formed from the video_path
|
|
260
|
+
# stripped of its sound
|
|
261
|
+
in1 = ffmpeg.input(_pathname(video_path))
|
|
262
|
+
video_extension = video_path.suffix
|
|
263
|
+
silenced_opts = ["-loglevel", "quiet", "-nostats", "-hide_banner"]
|
|
264
|
+
file_handle = tempfile.NamedTemporaryFile(suffix=video_extension,
|
|
265
|
+
delete=DEL_TEMP)
|
|
266
|
+
out1 = in1.output(file_handle.name, map='0:v', vcodec='copy')
|
|
267
|
+
ffmpeg.run([out1.global_args(*silenced_opts)], overwrite_output=True)
|
|
268
|
+
return file_handle
|
|
269
|
+
|
|
270
|
+
# logger.add(sys.stdout, filter=lambda r: r["function"] == "_change_audio4video")
|
|
271
|
+
def _change_audio4video(audio_path: Path, video: Path):
|
|
272
|
+
"""
|
|
273
|
+
Replaces audio in video (argument) by the audio contained in
|
|
274
|
+
audio_path (argument)
|
|
275
|
+
If name has version letter (A or B), flip it (DSC_8064vB.MOV -> DSC_8064vA.MOV)
|
|
276
|
+
Returns string tuple of old and new video files
|
|
277
|
+
old file is partial (without version and suffix) e.g.: ../DSC_8064
|
|
278
|
+
new file is complete
|
|
279
|
+
|
|
280
|
+
returns nothing
|
|
281
|
+
|
|
282
|
+
"""
|
|
283
|
+
vidname, video_ext = video.name.split('.')
|
|
284
|
+
vid_only_handle = _keep_VIDEO_only(video)
|
|
285
|
+
a_n = _pathname(audio_path)
|
|
286
|
+
v_n = _pathname(vid_only_handle)
|
|
287
|
+
# check v suffix
|
|
288
|
+
m = re.match('(.*)v([AB]*)', vidname)
|
|
289
|
+
if m == None:
|
|
290
|
+
logger.debug('no suffix, add one')
|
|
291
|
+
out_path = video.parent / f'{vidname}vA.{video_ext}'
|
|
292
|
+
out_n = _pathname(out_path)
|
|
293
|
+
# for Resolve search
|
|
294
|
+
partial_filename = str(video.parent / vidname)
|
|
295
|
+
else:
|
|
296
|
+
base, letter = m.groups()
|
|
297
|
+
logger.debug(f'base {base}, letter {letter}')
|
|
298
|
+
new_letter = 'B' if letter == 'A' else 'A'
|
|
299
|
+
logger.debug(f'new_letter {new_letter}')
|
|
300
|
+
out_path = video.parent / f'{base}v{new_letter}.{video_ext}'
|
|
301
|
+
out_n = _pathname(out_path)
|
|
302
|
+
# for Resolve search
|
|
303
|
+
partial_filename = str(video.parent / base)
|
|
304
|
+
print(f'Video [bold]{video}[/bold] \nhas new sound and is now [bold]{out_path}[/bold]')
|
|
305
|
+
logger.debug(f'partial filname {partial_filename}')
|
|
306
|
+
# old_file = str(video)
|
|
307
|
+
new_file = str(out_path)
|
|
308
|
+
video.unlink() # remove old one
|
|
309
|
+
# building args for debug purpose only:
|
|
310
|
+
ffmpeg_args = (
|
|
311
|
+
ffmpeg
|
|
312
|
+
.input(v_n)
|
|
313
|
+
.output(out_n, vcodec='copy')
|
|
314
|
+
# .output(out_n, shortest=None, vcodec='copy')
|
|
315
|
+
.global_args('-i', a_n, "-hide_banner")
|
|
316
|
+
.overwrite_output()
|
|
317
|
+
.get_args()
|
|
318
|
+
)
|
|
319
|
+
logger.debug('ffmpeg args: %s'%' '.join(ffmpeg_args))
|
|
320
|
+
try: # for real now
|
|
321
|
+
_, out = (
|
|
322
|
+
ffmpeg
|
|
323
|
+
.input(v_n)
|
|
324
|
+
# .output(out_n, shortest=None, vcodec='copy')
|
|
325
|
+
.output(out_n, vcodec='copy')
|
|
326
|
+
.global_args('-i', a_n, "-hide_banner")
|
|
327
|
+
.overwrite_output()
|
|
328
|
+
.run(capture_stderr=True)
|
|
329
|
+
)
|
|
330
|
+
logger.debug('ffmpeg output')
|
|
331
|
+
for l in out.decode("utf-8").split('\n'):
|
|
332
|
+
logger.debug(l)
|
|
333
|
+
except ffmpeg.Error as e:
|
|
334
|
+
print('ffmpeg.run error merging: \n\t %s + %s = %s\n'%(
|
|
335
|
+
audio_path,
|
|
336
|
+
video_path,
|
|
337
|
+
synced_clip_file
|
|
338
|
+
))
|
|
339
|
+
print(e)
|
|
340
|
+
print(e.stderr.decode('UTF-8'))
|
|
341
|
+
sys.exit(1)
|
|
342
|
+
return partial_filename, new_file
|
|
343
|
+
|
|
344
|
+
def _sox_combine(paths) -> Path:
|
|
345
|
+
"""
|
|
346
|
+
Combines (stacks) files referred by the list of Path into a new temporary
|
|
347
|
+
files passed on return each files are stacked in a different channel, so
|
|
348
|
+
len(paths) == n_channels
|
|
349
|
+
"""
|
|
350
|
+
if len(paths) == 1: # one device only, nothing to stack
|
|
351
|
+
logger.debug('one device only, nothing to stack')
|
|
352
|
+
return paths[0] ########################################################
|
|
353
|
+
out_file_handle = tempfile.NamedTemporaryFile(suffix='.wav',
|
|
354
|
+
delete=DEL_TEMP)
|
|
355
|
+
filenames = [_pathname(p) for p in paths]
|
|
356
|
+
out_file_name = _pathname(out_file_handle)
|
|
357
|
+
logger.debug('combining files: %s into %s'%(
|
|
358
|
+
filenames,
|
|
359
|
+
out_file_name))
|
|
360
|
+
cbn = sox.Combiner()
|
|
361
|
+
cbn.set_input_format(file_type=['wav']*len(paths))
|
|
362
|
+
status = cbn.build(
|
|
363
|
+
filenames,
|
|
364
|
+
out_file_name,
|
|
365
|
+
combine_type='merge')
|
|
366
|
+
logger.debug('sox.build status: %s'%status)
|
|
367
|
+
if status != True:
|
|
368
|
+
print('Error, sox did not merge files in _sox_combine()')
|
|
369
|
+
sys.exit(1)
|
|
370
|
+
merged_duration = sox.file_info.duration(
|
|
371
|
+
_pathname(out_file_handle))
|
|
372
|
+
nchan = sox.file_info.channels(
|
|
373
|
+
_pathname(out_file_handle))
|
|
374
|
+
logger.debug('merged file duration %f s with %i channels '%
|
|
375
|
+
(merged_duration, nchan))
|
|
376
|
+
return out_file_handle
|
|
377
|
+
|
|
378
|
+
def generate_script(new_mixes):
|
|
379
|
+
if LUA:
|
|
380
|
+
script = DAVINCI_RESOLVE_SCRIPT_TEMPLATE_LUA
|
|
381
|
+
postlude = ''
|
|
382
|
+
for a,b in new_mixes:
|
|
383
|
+
postlude += '{"%s","%s"},\n'%(a,b)
|
|
384
|
+
# postlude += f"('{a}','{b}'),\n"
|
|
385
|
+
postlude += '}\n\nfor _, pair in ipairs(changes) do\n'
|
|
386
|
+
postlude += ' findAndReplace(pair[1], pair[2])\n'
|
|
387
|
+
postlude += ' os.remove(pair[1])\n'
|
|
388
|
+
postlude += 'end\n'
|
|
389
|
+
# postlude += 'os.remove(pair[1])\n'
|
|
390
|
+
else: # python
|
|
391
|
+
script = DAVINCI_RESOLVE_SCRIPT_TEMPLATE
|
|
392
|
+
postlude = '\nchanges = [\n'
|
|
393
|
+
for a,b in new_mixes:
|
|
394
|
+
postlude += '("%s","%s"),\n'%(a,b)
|
|
395
|
+
postlude += ']\n'
|
|
396
|
+
postlude += '[findAndReplace(a,b) for a, b in changes]\n\n'
|
|
397
|
+
# foo = '[findAndReplace(a,b) for a, b in changes\n'
|
|
398
|
+
# print('foo',foo)
|
|
399
|
+
# print(postlude + foo)
|
|
400
|
+
return script + postlude
|
|
401
|
+
|
|
402
|
+
def main():
|
|
403
|
+
# proxies_dir, originals_dir, audio_dir, unique_directory, scan_only = \
|
|
404
|
+
# parse_and_check_arguments()
|
|
405
|
+
args = parse_and_check_arguments()
|
|
406
|
+
if args.unique_directory != None:
|
|
407
|
+
# alongside
|
|
408
|
+
matching_pairs = find_SND_vids_pairs_in_udir(args.unique_directory[0])
|
|
409
|
+
else:
|
|
410
|
+
# MAM mode
|
|
411
|
+
raw_root, synced_root, snd_root, proxies = mamconf.get_proj(False)
|
|
412
|
+
if raw_root == None:
|
|
413
|
+
print('Error: without -u option, mamsync should be configured before using newmix')
|
|
414
|
+
sys.exit(0)
|
|
415
|
+
|
|
416
|
+
# RAWROOT (sources with TC): "/Users/foobar/movies/MyBigMovie/"
|
|
417
|
+
# SYNCEDROOT (where RAWROOT will be mirrored, but with synced clips): "/Users/foobar/synced"
|
|
418
|
+
# SNDROOT (destination of ISOs sound files): "/Users/foobar/MovieSounds"
|
|
419
|
+
# then
|
|
420
|
+
# "/Users/foobar/synced/MyBigMovie" and "/Users/foobar/MovieSounds/MyBigMovie" are created
|
|
421
|
+
# So, synced_root is an enclosing directory, without the "project name" so
|
|
422
|
+
# fetch it from snd_root and build a synced_project pathlib.Path
|
|
423
|
+
proj_name = Path(raw_root).name
|
|
424
|
+
synced_project = Path(synced_root)/proj_name
|
|
425
|
+
project_sounds = Path(snd_root)/proj_name
|
|
426
|
+
matching_pairs = find_SND_vids_pairs_in_dual_dir(synced_project, project_sounds)
|
|
427
|
+
logger.debug(f'matching_pairs {pformat(matching_pairs)}')
|
|
428
|
+
changes = []
|
|
429
|
+
for SND_dir, vid in matching_pairs:
|
|
430
|
+
new_mix_files = get_recent_mix(SND_dir, vid)
|
|
431
|
+
logger.debug('new_mix_files: %s'%str(new_mix_files))
|
|
432
|
+
if new_mix_files != ():
|
|
433
|
+
logger.debug(f'new mixes {new_mix_files} in {SND_dir} for {vid.name}')
|
|
434
|
+
if len(new_mix_files) == 2:
|
|
435
|
+
new_audio_wav = _sox_combine(new_mix_files)
|
|
436
|
+
logger.debug('stereo_wav: %s'%new_audio_wav)
|
|
437
|
+
else: # len == 1, mono wav file
|
|
438
|
+
new_audio_wav = new_mix_files[0]
|
|
439
|
+
old_file, new_file = _change_audio4video(new_audio_wav, vid)
|
|
440
|
+
changes.append((old_file, new_file))
|
|
441
|
+
if len(changes) == 0:
|
|
442
|
+
print('No new mix.')
|
|
443
|
+
sys.exit(0)
|
|
444
|
+
logger.debug(f'changes {pformat(changes)}')
|
|
445
|
+
script = generate_script(changes)
|
|
446
|
+
fist_vid, _ = changes[0]
|
|
447
|
+
fist_name = Path(fist_vid).name
|
|
448
|
+
# title = f'New sound for {fist_name}'
|
|
449
|
+
# new_sound_clips = [p for p in Path(DAVINCI_RESOLVE_SCRIPT_LOCATION).iterdir() if 'New sound for' in str(p)]
|
|
450
|
+
# if len(new_sound_clips) != 0:
|
|
451
|
+
# print('There is already some "New sound for" Resolve scripts')
|
|
452
|
+
# for p in new_sound_clips:
|
|
453
|
+
# print(p)
|
|
454
|
+
# while ( res:=input("Is it OK to delete them? (you could also do it manually). Enter y/n: ").lower() ) not in {"y", "n"}: pass
|
|
455
|
+
# if res == 'y':
|
|
456
|
+
# [p.unlink() for p in new_sound_clips]
|
|
457
|
+
# # title = str(fist_vid)
|
|
458
|
+
title = 'Load New Sound'
|
|
459
|
+
if len(changes) > 1:
|
|
460
|
+
title += 's'
|
|
461
|
+
script_path = Path(DAVINCI_RESOLVE_SCRIPT_LOCATION)/f'{title}.lua'
|
|
462
|
+
# script += f'os.remove("{script_path}")\n' # doesnt work
|
|
463
|
+
with open(script_path, 'w') as fh:
|
|
464
|
+
fh.write(script)
|
|
465
|
+
print(f'Wrote script {script_path}')
|
|
466
|
+
# print(script)
|
|
467
|
+
|
|
468
|
+
if __name__ == '__main__':
|
|
469
|
+
main()
|
tictacsync/splitmix.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import argparse, sys, sox
|
|
2
|
+
from loguru import logger
|
|
3
|
+
from scipy.io.wavfile import write as wrt_wav
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from . import load_fieldr_reaper
|
|
7
|
+
from . import yaltc
|
|
8
|
+
except:
|
|
9
|
+
import load_fieldr_reaper
|
|
10
|
+
import yaltc
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger.level("DEBUG", color="<yellow>")
|
|
16
|
+
logger.add(sys.stdout, level="DEBUG")
|
|
17
|
+
# logger.remove()
|
|
18
|
+
|
|
19
|
+
def conf_and_parse_arguments():
|
|
20
|
+
# parses directories from command arguments
|
|
21
|
+
# check for consistencies and warn user and exits,
|
|
22
|
+
# returns parser.parse_args()
|
|
23
|
+
descr = "Parse the submitted OTIO timeline and split the specified mix wav file according to OTIO clips"
|
|
24
|
+
parser = argparse.ArgumentParser(description=descr)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"otio_file",
|
|
27
|
+
type=str,
|
|
28
|
+
nargs=1,
|
|
29
|
+
help="path of timeline saved under OTIO format"
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument('mix',
|
|
32
|
+
type=str,
|
|
33
|
+
nargs=1,
|
|
34
|
+
help="mix wav file to be splitted")
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
logger.debug('args %s'%args)
|
|
37
|
+
return args
|
|
38
|
+
|
|
39
|
+
# def write_wav(file, audio, samplerate):
|
|
40
|
+
# # Put the channels together with shape (2, 44100).
|
|
41
|
+
# # audio = np.array([left_channel, right_channel]).T
|
|
42
|
+
|
|
43
|
+
# audio = (audio * (2 ** 15 - 1)).astype("<h")
|
|
44
|
+
|
|
45
|
+
# with wave.open(file, "w") as f:
|
|
46
|
+
# # 2 Channels.
|
|
47
|
+
# f.setnchannels(2)
|
|
48
|
+
# # 2 bytes per sample.
|
|
49
|
+
# f.setsampwidth(2)
|
|
50
|
+
# f.setframerate(samplerate)
|
|
51
|
+
# f.writeframes(audio.tobytes())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main():
|
|
55
|
+
# [TODO] split but duplicate audio to fill in the trimed parts of each clip
|
|
56
|
+
# so use whole clip duration rather than cut_duration for audio length
|
|
57
|
+
args = conf_and_parse_arguments()
|
|
58
|
+
fps, clips =load_fieldr_reaper.read_OTIO_file(args.otio_file[0])
|
|
59
|
+
logger.debug(f'otio has {fps} fps')
|
|
60
|
+
wav_file = args.mix[0]
|
|
61
|
+
N_channels = sox.file_info.channels(wav_file)
|
|
62
|
+
logger.debug(f'{wav_file} has {N_channels} channels')
|
|
63
|
+
tracks = yaltc.read_audio_data_from_file(wav_file, N_channels)
|
|
64
|
+
audio_data = tracks.T # interleave channels for cutting later
|
|
65
|
+
logger.debug(f'audio data shape {audio_data.shape}')
|
|
66
|
+
logger.debug(f'data: {tracks}')
|
|
67
|
+
logger.debug(f'tracks shape {tracks.shape}')
|
|
68
|
+
# start_frames, the "in" frame number (absolute, ie first "in" is 0)
|
|
69
|
+
start_frames = [int(round(cl.timeline_pos*fps)) - 3600*fps for cl in clips]
|
|
70
|
+
logger.debug(f'start_frames {start_frames}')
|
|
71
|
+
durations = [int(round(cl.cut_duration*fps)) for cl in clips]
|
|
72
|
+
logger.debug(f'durations {durations}')
|
|
73
|
+
# sampling frequency, samples per second
|
|
74
|
+
sps = sox.file_info.sample_rate(wav_file)
|
|
75
|
+
# number of audio samples per frames,
|
|
76
|
+
spf = sps/fps
|
|
77
|
+
logger.debug(f'there are {spf} audio samples for each frame')
|
|
78
|
+
audio_slices = [audio_data[int(spf*s):int(spf*(s+d))] for s,d in zip(start_frames, durations)]
|
|
79
|
+
logger.debug(f'audio_slices lengths {[len(s) for s in audio_slices]}')
|
|
80
|
+
for a in audio_slices:
|
|
81
|
+
logger.debug(f'audio_slices {a}')
|
|
82
|
+
|
|
83
|
+
[wrt_wav(f'{clips[i].name.split(".")[0]}.wav', int(sps), a) for i, a in enumerate(audio_slices)]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == '__main__':
|
|
87
|
+
main()
|