tictacsync 0.3a4__tar.gz → 0.5a0__tar.gz
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-0.3a4/tictacsync.egg-info → tictacsync-0.5a0}/PKG-INFO +15 -9
- {tictacsync-0.3a4 → tictacsync-0.5a0}/README.md +14 -8
- {tictacsync-0.3a4 → tictacsync-0.5a0}/setup.py +1 -1
- {tictacsync-0.3a4 → tictacsync-0.5a0}/tictacsync/device_scanner.py +20 -26
- {tictacsync-0.3a4 → tictacsync-0.5a0}/tictacsync/entry.py +7 -10
- {tictacsync-0.3a4 → tictacsync-0.5a0}/tictacsync/multi2polywav.py +2 -1
- tictacsync-0.5a0/tictacsync/remergemix.py +257 -0
- {tictacsync-0.3a4 → tictacsync-0.5a0}/tictacsync/timeline.py +353 -196
- {tictacsync-0.3a4 → tictacsync-0.5a0}/tictacsync/yaltc.py +179 -171
- {tictacsync-0.3a4 → tictacsync-0.5a0/tictacsync.egg-info}/PKG-INFO +15 -9
- tictacsync-0.3a4/tictacsync/remergemix.py +0 -144
- {tictacsync-0.3a4 → tictacsync-0.5a0}/LICENSE +0 -0
- {tictacsync-0.3a4 → tictacsync-0.5a0}/setup.cfg +0 -0
- {tictacsync-0.3a4 → tictacsync-0.5a0}/tictacsync/__init__.py +0 -0
- {tictacsync-0.3a4 → tictacsync-0.5a0}/tictacsync.egg-info/SOURCES.txt +0 -0
- {tictacsync-0.3a4 → tictacsync-0.5a0}/tictacsync.egg-info/dependency_links.txt +0 -0
- {tictacsync-0.3a4 → tictacsync-0.5a0}/tictacsync.egg-info/entry_points.txt +0 -0
- {tictacsync-0.3a4 → tictacsync-0.5a0}/tictacsync.egg-info/not-zip-safe +0 -0
- {tictacsync-0.3a4 → tictacsync-0.5a0}/tictacsync.egg-info/requires.txt +0 -0
- {tictacsync-0.3a4 → tictacsync-0.5a0}/tictacsync.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: tictacsync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5a0
|
|
4
4
|
Summary: command for syncing audio video recordings
|
|
5
5
|
Home-page: https://tictacsync.org/
|
|
6
6
|
Author: Raymond Lutz
|
|
@@ -29,15 +29,21 @@ Unfinished sloppy code ahead, but should run without errors. Some functionalitie
|
|
|
29
29
|
|
|
30
30
|
## Description
|
|
31
31
|
|
|
32
|
-
`tictacsync` is a python script to sync
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
another longitudinal time code* and should be recorded on a scratch
|
|
36
|
-
track on each device for the syncing to be performed, later in _postprod_ before editing.
|
|
37
|
-
|
|
32
|
+
`tictacsync` is a python script to sync, cut and join audio files against camera files shot using a specific hardware timecode generator
|
|
33
|
+
called [Tic Tac Sync](https://tictacsync.org). The timecode is named TicTacCode and should be recorded on a scratch
|
|
34
|
+
track on each device for `tictacsync` to work.
|
|
38
35
|
## Status
|
|
39
36
|
|
|
40
|
-
`tictacsync` scans for audio video files and
|
|
37
|
+
Feature complete! `tictacsync` scans for audio video files and then merges overlapping audio and video recordings, It
|
|
38
|
+
|
|
39
|
+
* Decodes the TicTacCode audio track alongside your audio tracks
|
|
40
|
+
* Establishes UTC start time (and end time) within 100 μs!
|
|
41
|
+
* Syncs, cuts and joins any concurrent audio to camera files (using `FFmpeg`)
|
|
42
|
+
* Processes _multiple_ audio recorders
|
|
43
|
+
* Corrects device clock drift so _both_ ends coincide (thanks to `sox`)
|
|
44
|
+
* Sets video metadata TC of multicam files for NLE timeline alignement
|
|
45
|
+
* Writes _synced_ ISO files with dedicated file names declared in `tracks.txt`
|
|
46
|
+
* Produces nice plots.
|
|
41
47
|
|
|
42
48
|
|
|
43
49
|
## Installation
|
|
@@ -77,7 +83,7 @@ For a one line output (or to suppress the progress bars) use the `--terse` flag:
|
|
|
77
83
|
> tictacsync --terse dailies/loose/MVI_0024.MP4
|
|
78
84
|
dailies/loose/MVI_0024.MP4 UTC:2024-03-12 23:07:01.4281 pulse: 27450 in chan 0
|
|
79
85
|
|
|
80
|
-
To also produce _synced_ ISO audio files, specify `--isos` . A directory named `ISOs` will contain _for each synced video_ a set of audio files of exact same length, padded or trimmed to coincide with the video track. After re-editing and re-mixing a `remergemix` command will resync the new
|
|
86
|
+
To also produce _synced_ ISO audio files, specify `--isos` . A directory named `ISOs` will contain _for each synced video_ a set of ISO audio files of exact same length, padded or trimmed to coincide with the video track. After re-editing and re-mixing a `remergemix` command will resync the new audio with the video and _the new sound track will be updated on your NLE timeline_, at least in Kdenlive...
|
|
81
87
|
|
|
82
88
|
> tictacsync --isos dailies/structured
|
|
83
89
|
|
|
@@ -6,15 +6,21 @@ Unfinished sloppy code ahead, but should run without errors. Some functionalitie
|
|
|
6
6
|
|
|
7
7
|
## Description
|
|
8
8
|
|
|
9
|
-
`tictacsync` is a python script to sync
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
another longitudinal time code* and should be recorded on a scratch
|
|
13
|
-
track on each device for the syncing to be performed, later in _postprod_ before editing.
|
|
14
|
-
|
|
9
|
+
`tictacsync` is a python script to sync, cut and join audio files against camera files shot using a specific hardware timecode generator
|
|
10
|
+
called [Tic Tac Sync](https://tictacsync.org). The timecode is named TicTacCode and should be recorded on a scratch
|
|
11
|
+
track on each device for `tictacsync` to work.
|
|
15
12
|
## Status
|
|
16
13
|
|
|
17
|
-
`tictacsync` scans for audio video files and
|
|
14
|
+
Feature complete! `tictacsync` scans for audio video files and then merges overlapping audio and video recordings, It
|
|
15
|
+
|
|
16
|
+
* Decodes the TicTacCode audio track alongside your audio tracks
|
|
17
|
+
* Establishes UTC start time (and end time) within 100 μs!
|
|
18
|
+
* Syncs, cuts and joins any concurrent audio to camera files (using `FFmpeg`)
|
|
19
|
+
* Processes _multiple_ audio recorders
|
|
20
|
+
* Corrects device clock drift so _both_ ends coincide (thanks to `sox`)
|
|
21
|
+
* Sets video metadata TC of multicam files for NLE timeline alignement
|
|
22
|
+
* Writes _synced_ ISO files with dedicated file names declared in `tracks.txt`
|
|
23
|
+
* Produces nice plots.
|
|
18
24
|
|
|
19
25
|
|
|
20
26
|
## Installation
|
|
@@ -54,7 +60,7 @@ For a one line output (or to suppress the progress bars) use the `--terse` flag:
|
|
|
54
60
|
> tictacsync --terse dailies/loose/MVI_0024.MP4
|
|
55
61
|
dailies/loose/MVI_0024.MP4 UTC:2024-03-12 23:07:01.4281 pulse: 27450 in chan 0
|
|
56
62
|
|
|
57
|
-
To also produce _synced_ ISO audio files, specify `--isos` . A directory named `ISOs` will contain _for each synced video_ a set of audio files of exact same length, padded or trimmed to coincide with the video track. After re-editing and re-mixing a `remergemix` command will resync the new
|
|
63
|
+
To also produce _synced_ ISO audio files, specify `--isos` . A directory named `ISOs` will contain _for each synced video_ a set of ISO audio files of exact same length, padded or trimmed to coincide with the video track. After re-editing and re-mixing a `remergemix` command will resync the new audio with the video and _the new sound track will be updated on your NLE timeline_, at least in Kdenlive...
|
|
58
64
|
|
|
59
65
|
> tictacsync --isos dailies/structured
|
|
60
66
|
|
|
@@ -77,6 +77,7 @@ class Device:
|
|
|
77
77
|
name: str
|
|
78
78
|
dev_type: str # CAM or REC
|
|
79
79
|
n_chan: int
|
|
80
|
+
ttc: int
|
|
80
81
|
tracks: Tracks
|
|
81
82
|
def __hash__(self):
|
|
82
83
|
return self.UID
|
|
@@ -89,10 +90,12 @@ class Media:
|
|
|
89
90
|
"""
|
|
90
91
|
path: Path
|
|
91
92
|
device: Device
|
|
93
|
+
|
|
92
94
|
def media_at_path(input_structure, p):
|
|
93
95
|
# return Media object for mediafile using ffprobe
|
|
94
96
|
dev_UID, dt = get_device_ffprobe_UID(p)
|
|
95
|
-
dev_name = None
|
|
97
|
+
dev_name = None
|
|
98
|
+
logger.debug('ffprobe dev_UID:%s dt:%s'%(dev_UID, dt))
|
|
96
99
|
if input_structure == 'folder_is_device':
|
|
97
100
|
dev_name = p.parent.name
|
|
98
101
|
if dev_UID is None:
|
|
@@ -113,9 +116,10 @@ def media_at_path(input_structure, p):
|
|
|
113
116
|
else:
|
|
114
117
|
n = sox.file_info.channels(_pathname(p)) # eg 2
|
|
115
118
|
logger.debug('for file %s dev_UID established %s'%(p.name, dev_UID))
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
+
device = Device(UID=dev_UID, folder=p.parent, name=dev_name, dev_type=dt,
|
|
120
|
+
n_chan=n, ttc=None, tracks=None)
|
|
121
|
+
logger.debug('for path: %s, device:%s'%(p,device))
|
|
122
|
+
return Media(p, device)
|
|
119
123
|
|
|
120
124
|
def get_device_ffprobe_UID(file):
|
|
121
125
|
"""
|
|
@@ -140,7 +144,7 @@ def get_device_ffprobe_UID(file):
|
|
|
140
144
|
except ffmpeg.Error as e:
|
|
141
145
|
print('ffmpeg.probe error')
|
|
142
146
|
print(e.stderr, file)
|
|
143
|
-
return None, None
|
|
147
|
+
return None, None #-----------------------------------------------------
|
|
144
148
|
# fall back to folder name
|
|
145
149
|
streams = probe['streams']
|
|
146
150
|
codecs = [stream['codec_type'] for stream in streams]
|
|
@@ -154,9 +158,12 @@ def get_device_ffprobe_UID(file):
|
|
|
154
158
|
and 'date' not in l ]
|
|
155
159
|
# this removes any metadata related to the file
|
|
156
160
|
# but keeps metadata related to the device
|
|
161
|
+
logger.debug('probe_lines %s'%probe_lines)
|
|
157
162
|
UID = hash(''.join(probe_lines))
|
|
158
163
|
else:
|
|
159
164
|
UID = None
|
|
165
|
+
if UID == 0: # empty probe_lines from Audacity ?!?
|
|
166
|
+
UID = None
|
|
160
167
|
logger.debug('ffprobe_UID is: %s'%UID)
|
|
161
168
|
return UID, device_type
|
|
162
169
|
|
|
@@ -256,9 +263,6 @@ class Scanner:
|
|
|
256
263
|
for p in paths:
|
|
257
264
|
new_media = media_at_path(self.input_structure, p) # dev UID set here
|
|
258
265
|
self.found_media_files.append(new_media)
|
|
259
|
-
# files from devices without UID or name
|
|
260
|
-
# def _list_all_the_same(l):
|
|
261
|
-
# return all(e == l[0] for e in l)
|
|
262
266
|
def _try_name(medias):
|
|
263
267
|
# return common first strings in filename
|
|
264
268
|
names = [m.path.name for m in medias]
|
|
@@ -273,7 +277,6 @@ class Scanner:
|
|
|
273
277
|
if not m.device.UID]
|
|
274
278
|
if no_device_UID_media:
|
|
275
279
|
logger.debug('no_device_UID_media %s'%no_device_UID_media)
|
|
276
|
-
# print(no_device_UID_media)
|
|
277
280
|
start_string = _try_name(no_device_UID_media)
|
|
278
281
|
if len(start_string) < 2:
|
|
279
282
|
print('\nError, cant identify the device for those files:')
|
|
@@ -285,7 +288,8 @@ class Scanner:
|
|
|
285
288
|
if not one_device.UID:
|
|
286
289
|
one_device.UID = hash(start_string)
|
|
287
290
|
print('\nWarning, guessing a device ID for those files:')
|
|
288
|
-
[print('[gold1]%s[/gold1], '%m.path.name, end='') for m
|
|
291
|
+
[print('[gold1]%s[/gold1], '%m.path.name, end='') for m
|
|
292
|
+
in no_device_UID_media]
|
|
289
293
|
print('UID: [gold1]%s[/gold1]'%start_string)
|
|
290
294
|
for m in no_device_UID_media:
|
|
291
295
|
m.device = one_device
|
|
@@ -342,32 +346,22 @@ class Scanner:
|
|
|
342
346
|
ntracks += len(tracks.others)
|
|
343
347
|
ntracks += 1 # for ttc track
|
|
344
348
|
logger.debug(' n chan: %i n tracks file: %i'%(nchan, ntracks))
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
349
|
+
if ntracks != nchan:
|
|
350
|
+
print('\nError parsing %s content'%tracks_file)
|
|
351
|
+
print('incoherent number of tracks, %i vs %i quitting\n'%
|
|
352
|
+
(nchan, ntracks))
|
|
353
|
+
sys.exit(1)
|
|
350
354
|
err_msg = tracks.error_msg
|
|
351
355
|
if err_msg != None:
|
|
352
356
|
print('Error, quitting: in file %s, %s'%(tracks_file, err_msg))
|
|
353
357
|
raise Exception
|
|
354
358
|
else:
|
|
359
|
+
logger.debug('tracks object%s'%tracks)
|
|
355
360
|
return tracks
|
|
356
361
|
else:
|
|
357
362
|
logger.debug('no tracks.txt file found')
|
|
358
363
|
return None
|
|
359
364
|
|
|
360
|
-
# def _use_folder_as_device_name(self):
|
|
361
|
-
# """
|
|
362
|
-
# For each media in self.found_media_files replace existing Device.name by
|
|
363
|
-
# folder name.
|
|
364
|
-
|
|
365
|
-
# Returns nothing
|
|
366
|
-
# """
|
|
367
|
-
# for m in self.found_media_files:
|
|
368
|
-
# m.device.name = m.path.parent.name
|
|
369
|
-
# logger.debug(self.found_media_files)
|
|
370
|
-
|
|
371
365
|
def _check_folders_have_same_device(self):
|
|
372
366
|
"""
|
|
373
367
|
Since input_structure == 'folder_is_device,
|
|
@@ -126,10 +126,10 @@ def main():
|
|
|
126
126
|
# logger.add(sys.stdout, filter="__main__")
|
|
127
127
|
# logger.add(sys.stdout, filter="device_scanner")
|
|
128
128
|
# logger.add(sys.stdout, filter="yaltc") _extract_sound_to_merge
|
|
129
|
+
# logger.add(sys.stdout, filter=lambda r: r["function"] == "media_at_path")
|
|
129
130
|
# logger.add(sys.stdout, filter=lambda r: r["function"] == "scan_media_and_build_devices_UID")
|
|
130
|
-
# logger.add(sys.stdout, filter=lambda r: r["function"] == "
|
|
131
|
-
# logger.add(sys.stdout, filter=lambda r: r["function"] == "
|
|
132
|
-
# logger.add(sys.stdout, filter=lambda r: r["function"] == "_sox_mix")
|
|
131
|
+
# logger.add(sys.stdout, filter=lambda r: r["function"] == "_get_device_mix")
|
|
132
|
+
# logger.add(sys.stdout, filter=lambda r: r["function"] == "_sox_mix_files")
|
|
133
133
|
top_dir = args.directory[0]
|
|
134
134
|
if os.path.isfile(top_dir):
|
|
135
135
|
file = top_dir
|
|
@@ -234,7 +234,7 @@ def main():
|
|
|
234
234
|
print('\nNothing to sync, exiting.\n')
|
|
235
235
|
sys.exit(1)
|
|
236
236
|
matcher = timeline.Matcher(recordings_with_time)
|
|
237
|
-
matcher.
|
|
237
|
+
matcher.scan_audio_for_each_videoclip()
|
|
238
238
|
if not matcher.video_mergers:
|
|
239
239
|
if not args.terse:
|
|
240
240
|
print('\nNothing to sync, bye.\n')
|
|
@@ -263,11 +263,11 @@ def main():
|
|
|
263
263
|
print('\nWrote output in folder [gold1]%s[/gold1]'%(
|
|
264
264
|
a_stitcher.synced_clip_dir))
|
|
265
265
|
for stitcher in matcher.video_mergers:
|
|
266
|
-
print('[gold1]%s[/gold1]'%stitcher.
|
|
266
|
+
print('[gold1]%s[/gold1]'%stitcher.videoclip.AVpath.name, end='')
|
|
267
267
|
for audio in stitcher.get_matched_audio_recs():
|
|
268
268
|
print(' + [gold1]%s[/gold1]'%audio.AVpath.name, end='')
|
|
269
|
-
new_file = stitcher.
|
|
270
|
-
print(' became [gold1]%s[/gold1]'%stitcher.
|
|
269
|
+
new_file = stitcher.videoclip.final_synced_file.parts
|
|
270
|
+
print(' became [gold1]%s[/gold1]'%stitcher.videoclip.final_synced_file.name)
|
|
271
271
|
# matcher._build_otio_tracks_for_cam()
|
|
272
272
|
matcher.shrink_gaps_between_takes()
|
|
273
273
|
sys.exit(0)
|
|
@@ -277,6 +277,3 @@ if __name__ == '__main__':
|
|
|
277
277
|
|
|
278
278
|
|
|
279
279
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
@@ -61,7 +61,7 @@ def build_poly_name(multifiles):
|
|
|
61
61
|
"""
|
|
62
62
|
Returns string of polywav filename, constructed from similitudes between
|
|
63
63
|
multifile names. Ex:
|
|
64
|
-
|
|
64
|
+
4CH002I.wav and 4CH002M.wav returns 4CH002X.wav
|
|
65
65
|
"""
|
|
66
66
|
s1 = str(multifiles[0].stem)
|
|
67
67
|
s2 = str(multifiles[1].stem)
|
|
@@ -96,6 +96,7 @@ def build_poly(multifiles):
|
|
|
96
96
|
# multifiles is list of Path
|
|
97
97
|
# change extensions to mfw (multifile wav)
|
|
98
98
|
dir_multi = multifiles[0].parent
|
|
99
|
+
multifiles.reverse()
|
|
99
100
|
poly_name_b = build_poly_name(multifiles) # base only
|
|
100
101
|
poly_name = str(dir_multi/Path(poly_name_b))
|
|
101
102
|
filenames = [str(p) for p in multifiles]
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import argparse, wave, subprocess
|
|
2
|
+
from loguru import logger
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sox, tempfile, os, ffmpeg
|
|
5
|
+
from rich import print
|
|
6
|
+
import shutil, sys, re
|
|
7
|
+
from pprint import pformat
|
|
8
|
+
from itertools import groupby
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from . import timeline
|
|
12
|
+
except:
|
|
13
|
+
import timeline
|
|
14
|
+
|
|
15
|
+
DEL_TEMP = False
|
|
16
|
+
|
|
17
|
+
logger.level("DEBUG", color="<yellow>")
|
|
18
|
+
logger.remove()
|
|
19
|
+
|
|
20
|
+
# logger.add(sys.stdout, filter=lambda r: r["function"] == "_get_ISO_dirs")
|
|
21
|
+
# logger.add(sys.stdout, filter=lambda r: r["function"] == "_join_audio2video")
|
|
22
|
+
# logger.add(sys.stdout, filter=lambda r: r["function"] == "main")
|
|
23
|
+
# logger.add(sys.stdout, filter="__main__")
|
|
24
|
+
|
|
25
|
+
OUT_DIR = 'SyncedMedia'
|
|
26
|
+
SEC_DELAY_CHANGED_ISO = 10 #sec, ISO_DIR changed if diff time is bigger
|
|
27
|
+
|
|
28
|
+
video_extensions = \
|
|
29
|
+
"""webm mkv flv flv vob ogv ogg drc gif gifv mng avi mov
|
|
30
|
+
qt wmv yuv rm rmvb viv asf mp4 m4p m4v mpg mp2 mpeg mpe
|
|
31
|
+
mpv mpg mpeg m2v m4v svi 3gp 3g2 mxf roq nsv""".split() # from wikipedia
|
|
32
|
+
|
|
33
|
+
def _pathname(tempfile_or_path) -> str:
|
|
34
|
+
# utility for obtaining a str from different filesystem objects
|
|
35
|
+
if isinstance(tempfile_or_path, str):
|
|
36
|
+
return tempfile_or_path
|
|
37
|
+
if isinstance(tempfile_or_path, Path):
|
|
38
|
+
return str(tempfile_or_path)
|
|
39
|
+
if isinstance(tempfile_or_path, tempfile._TemporaryFileWrapper):
|
|
40
|
+
return tempfile_or_path.name
|
|
41
|
+
else:
|
|
42
|
+
raise Exception('%s should be Path or tempfile...'%tempfile_or_path)
|
|
43
|
+
|
|
44
|
+
def _keep_VIDEO_only(video_path):
|
|
45
|
+
# return file handle to a temp video file formed from the video_path
|
|
46
|
+
# stripped of its sound
|
|
47
|
+
in1 = ffmpeg.input(_pathname(video_path))
|
|
48
|
+
video_extension = video_path.suffix
|
|
49
|
+
silenced_opts = ["-loglevel", "quiet", "-nostats", "-hide_banner"]
|
|
50
|
+
file_handle = tempfile.NamedTemporaryFile(suffix=video_extension,
|
|
51
|
+
delete=DEL_TEMP)
|
|
52
|
+
out1 = in1.output(file_handle.name, map='0:v', vcodec='copy')
|
|
53
|
+
ffmpeg.run([out1.global_args(*silenced_opts)], overwrite_output=True)
|
|
54
|
+
return file_handle
|
|
55
|
+
|
|
56
|
+
def _join_audio2video(audio_path: Path, video: Path):
|
|
57
|
+
"""
|
|
58
|
+
Replace audio in video (argument) by the audio contained in
|
|
59
|
+
audio_path (argument) returns nothing
|
|
60
|
+
"""
|
|
61
|
+
video_ext = video.name.split('.')[1]
|
|
62
|
+
vid_only_handle = _keep_VIDEO_only(video)
|
|
63
|
+
a_n = _pathname(audio_path)
|
|
64
|
+
v_n = _pathname(vid_only_handle)
|
|
65
|
+
out_n = _pathname(video)
|
|
66
|
+
# building args for debug purpose only:
|
|
67
|
+
ffmpeg_args = (
|
|
68
|
+
ffmpeg
|
|
69
|
+
.input(v_n)
|
|
70
|
+
.output(out_n, shortest=None, vcodec='copy')
|
|
71
|
+
.global_args('-i', a_n, "-hide_banner")
|
|
72
|
+
.overwrite_output()
|
|
73
|
+
.get_args()
|
|
74
|
+
)
|
|
75
|
+
logger.debug('ffmpeg args: %s'%' '.join(ffmpeg_args))
|
|
76
|
+
try: # for real now
|
|
77
|
+
_, out = (
|
|
78
|
+
ffmpeg
|
|
79
|
+
.input(v_n)
|
|
80
|
+
.output(out_n, shortest=None, vcodec='copy')
|
|
81
|
+
.global_args('-i', a_n, "-hide_banner")
|
|
82
|
+
.overwrite_output()
|
|
83
|
+
.run(capture_stderr=True)
|
|
84
|
+
)
|
|
85
|
+
logger.debug('ffmpeg output')
|
|
86
|
+
for l in out.decode("utf-8").split('\n'):
|
|
87
|
+
logger.debug(l)
|
|
88
|
+
except ffmpeg.Error as e:
|
|
89
|
+
print('ffmpeg.run error merging: \n\t %s + %s = %s\n'%(
|
|
90
|
+
audio_path,
|
|
91
|
+
video_path,
|
|
92
|
+
synced_clip_file
|
|
93
|
+
))
|
|
94
|
+
print(e)
|
|
95
|
+
print(e.stderr.decode('UTF-8'))
|
|
96
|
+
sys.exit(1)
|
|
97
|
+
|
|
98
|
+
def _changed(dir) -> bool:
|
|
99
|
+
"""
|
|
100
|
+
Returns True if any content of dir (arg) is more recent than dir itself by a
|
|
101
|
+
delay of SEC_DELAY_CHANGED_ISO. Uses modification times.
|
|
102
|
+
"""
|
|
103
|
+
logger.debug(f'checking {dir.name} for change')
|
|
104
|
+
ISO_modification_time = biggest_mtime_in_dir(dir)
|
|
105
|
+
clip = clip_from_iso(dir)
|
|
106
|
+
clip_mod_time = clip.stat().st_mtime
|
|
107
|
+
# difference of modification time in secs
|
|
108
|
+
ISO_more_recent_by = ISO_modification_time - clip_mod_time
|
|
109
|
+
logger.debug('ISO_more_recent_by: %s'%(ISO_more_recent_by))
|
|
110
|
+
iso_edited = ISO_more_recent_by > SEC_DELAY_CHANGED_ISO
|
|
111
|
+
logger.debug(f'_changed: {iso_edited}')
|
|
112
|
+
return iso_edited
|
|
113
|
+
|
|
114
|
+
def sox_mix(ISOdir):
|
|
115
|
+
"""
|
|
116
|
+
Mixes all wav files present in ISOdir (so excludes ttc file and nullified
|
|
117
|
+
ones).
|
|
118
|
+
Returns a mono or stereo tempfile
|
|
119
|
+
"""
|
|
120
|
+
logger.debug(f'mixing {ISOdir}')
|
|
121
|
+
def is_stereo_mic(p):
|
|
122
|
+
re_result = re.match(r'mic([lrLR])*', p.name)
|
|
123
|
+
# logger.debug(f're_result {re_result}')
|
|
124
|
+
return re_result is not None
|
|
125
|
+
stereo_mics = [p for p in ISOdir.iterdir() if is_stereo_mic(p)]
|
|
126
|
+
monofiles = [p for p in ISOdir.iterdir() if p not in stereo_mics]
|
|
127
|
+
# removing ttc files
|
|
128
|
+
def notTTC(p):
|
|
129
|
+
return p.name[:3] != 'ttc'
|
|
130
|
+
monofiles = [p for p in monofiles if notTTC(p)]
|
|
131
|
+
if stereo_mics == []: # mono
|
|
132
|
+
return timeline._sox_mix_files(monofiles) #-----------------------------
|
|
133
|
+
logger.debug(f'stereo_mics: {stereo_mics}')
|
|
134
|
+
def mic(p):
|
|
135
|
+
return re.search(r'(mic\d*)([lrLR])*', p.name).groups()
|
|
136
|
+
mics = [mic(p) for p in stereo_mics]
|
|
137
|
+
p_and_mic = list(zip(stereo_mics, mics))
|
|
138
|
+
logger.debug(f'p_and_mic: {p_and_mic}')
|
|
139
|
+
same_mic_key = lambda pair: pair[1][0]
|
|
140
|
+
p_and_mic = sorted(p_and_mic, key=same_mic_key)
|
|
141
|
+
grouped_by_mic = [ (k, list(iterator)) for k, iterator
|
|
142
|
+
in groupby(p_and_mic, same_mic_key)]
|
|
143
|
+
logger.debug(f'grouped_by_mic: {grouped_by_mic}')
|
|
144
|
+
def order_left_right(groupby_element):
|
|
145
|
+
# returns left and right path for a mic
|
|
146
|
+
name, paths = groupby_element
|
|
147
|
+
def chan(pair):
|
|
148
|
+
# (PosixPath('mic1r_ZOOM.wav'), ('mic1', 'r')) -> 'r'
|
|
149
|
+
return pair[1][1]
|
|
150
|
+
path_n_mic = sorted(paths, key=lambda pair: pair[1][1])
|
|
151
|
+
return [p[0] for p in path_n_mic] # just the path, not ('mic1', 'r')
|
|
152
|
+
left_right_paths = [order_left_right(e) for e in grouped_by_mic]
|
|
153
|
+
# logger.debug(f'left_right_paths: {left_right_paths}')
|
|
154
|
+
stereo_files = [timeline._sox_combine(pair) for pair in left_right_paths]
|
|
155
|
+
monoNstereo = monofiles + stereo_files
|
|
156
|
+
return timeline._sox_mix_files(monoNstereo)
|
|
157
|
+
|
|
158
|
+
def get_mix_file(iso_dir):
|
|
159
|
+
"""
|
|
160
|
+
If iso_dir (arg) contains a mono mix sound file or a stereo mix, returns its
|
|
161
|
+
path. If not, this creates the mix and returns it.
|
|
162
|
+
"""
|
|
163
|
+
wav_files = list(iso_dir.iterdir())
|
|
164
|
+
logger.debug(f'wav_files {wav_files}')
|
|
165
|
+
def is_mix(p):
|
|
166
|
+
re_result = re.match(r'mix([lrLR])*', p.name)
|
|
167
|
+
# logger.debug(f're_result {re_result}')
|
|
168
|
+
return re_result is not None
|
|
169
|
+
location_mix = [p for p in wav_files if is_mix(p)]
|
|
170
|
+
if location_mix == []:
|
|
171
|
+
logger.debug('no mix track, do the mix')
|
|
172
|
+
return sox_mix(iso_dir)
|
|
173
|
+
else:
|
|
174
|
+
return location_mix
|
|
175
|
+
|
|
176
|
+
def biggest_mtime_in_dir(folder: Path) -> float:
|
|
177
|
+
# return the most recent mod time of the files in a folder
|
|
178
|
+
dir_content = list(folder.iterdir())
|
|
179
|
+
stats = [p.stat() for p in dir_content]
|
|
180
|
+
mtimes = [stat.st_mtime for stat in stats]
|
|
181
|
+
return max(mtimes)
|
|
182
|
+
|
|
183
|
+
def clip_from_iso(ISO_dir: Path) -> Path:
|
|
184
|
+
# find the sibling video file of ISO_dir. eg : MVI_01.ISO -> MVI_01.MP4
|
|
185
|
+
folder = ISO_dir.parent
|
|
186
|
+
siblings = list(folder.glob('%s.*'%ISO_dir.stem))
|
|
187
|
+
candidates =[p for p in siblings if p.name.split('.')[1] != 'ISO']
|
|
188
|
+
# should be unique
|
|
189
|
+
if len(candidates) != 1:
|
|
190
|
+
print(f'Error finding video corresponding to {ISO_dir}, quitting')
|
|
191
|
+
sys.exit(1)
|
|
192
|
+
return candidates[0]
|
|
193
|
+
|
|
194
|
+
def _get_ISO_dirs(top_dir):
|
|
195
|
+
"""
|
|
196
|
+
Check if top_dir contains (or somewhere beneath) videos with their
|
|
197
|
+
accompanying ISO folder (like the pair MVI_023.MP4 + MVI_023.ISO). If not
|
|
198
|
+
warns and exits.
|
|
199
|
+
|
|
200
|
+
Returns list of paths pointing to ISO dirs.
|
|
201
|
+
"""
|
|
202
|
+
p = Path(top_dir)
|
|
203
|
+
ISO_dirs = list(Path(top_dir).rglob('*.ISO'))
|
|
204
|
+
logger.debug('all files: %s'%pformat(ISO_dirs))
|
|
205
|
+
# validation: .ISO should be dir
|
|
206
|
+
all_are_dir = all([p.is_dir() for p in ISO_dirs])
|
|
207
|
+
logger.debug('.ISO are all dir %s'%all_are_dir)
|
|
208
|
+
if not all_are_dir:
|
|
209
|
+
print('Error: some .ISO are not folders??? Quitting. %s'%ISO_dirs)
|
|
210
|
+
sys.exit(1)
|
|
211
|
+
# for each folder check a video file exists with the same stem
|
|
212
|
+
# but with a video format extension (listed in video_extensions)
|
|
213
|
+
for ISO in ISO_dirs:
|
|
214
|
+
logger.debug(f'checking {ISO}')
|
|
215
|
+
voisins = list(ISO.parent.glob(f'{ISO.stem}.*'))
|
|
216
|
+
voisins_suffixes = [p.suffix for p in voisins]
|
|
217
|
+
# remove ISO
|
|
218
|
+
voisins_suffixes.remove('.ISO')
|
|
219
|
+
# validations: should remain one element and should be video
|
|
220
|
+
suffix = voisins_suffixes[0].lower()[1:] # remove dot
|
|
221
|
+
logger.debug(f'remaining ext: {suffix}')
|
|
222
|
+
if len(voisins_suffixes) != 1 or suffix not in video_extensions:
|
|
223
|
+
print(f'Error with {voisins}, no video unique sibling?')
|
|
224
|
+
sys.exit(1) #-------------------------------------------------------
|
|
225
|
+
logger.debug(f'All ok, returning {ISO_dirs}')
|
|
226
|
+
return ISO_dirs
|
|
227
|
+
|
|
228
|
+
def main():
|
|
229
|
+
parser = argparse.ArgumentParser()
|
|
230
|
+
parser.add_argument(
|
|
231
|
+
"directory",
|
|
232
|
+
type=str,
|
|
233
|
+
nargs=1,
|
|
234
|
+
help="path of media directory containing Synced videos and their .ISO folder",
|
|
235
|
+
default='.'
|
|
236
|
+
)
|
|
237
|
+
args = parser.parse_args()
|
|
238
|
+
logger.debug('args %s'%args)
|
|
239
|
+
ISO_dirs = _get_ISO_dirs(args.directory[0])
|
|
240
|
+
logger.debug(f'Will check any change in {pformat(ISO_dirs)}')
|
|
241
|
+
changed_ISOs = [isod for isod in ISO_dirs if _changed(isod)]
|
|
242
|
+
logger.debug(f'changed_ISOs: {changed_ISOs}')
|
|
243
|
+
if changed_ISOs != []:
|
|
244
|
+
print('Will remix audio for:')
|
|
245
|
+
for p in changed_ISOs:
|
|
246
|
+
print(p.name)
|
|
247
|
+
newaudio_and_videos = [(get_mix_file(iso), clip_from_iso(iso)) for iso
|
|
248
|
+
in changed_ISOs]
|
|
249
|
+
for audio, video_clip in newaudio_and_videos:
|
|
250
|
+
print(f'Will remerge {video_clip.name}')
|
|
251
|
+
_join_audio2video(audio, video_clip)
|
|
252
|
+
if newaudio_and_videos == []:
|
|
253
|
+
print('Nothing has changed, bye.')
|
|
254
|
+
sys.exit(0)
|
|
255
|
+
|
|
256
|
+
if __name__ == '__main__':
|
|
257
|
+
main()
|