tictacsync 0.97a0__py3-none-any.whl → 0.98a0__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 +199 -107
- tictacsync/entry.py +177 -75
- tictacsync/remrgmx.py +87 -5
- tictacsync/timeline.py +195 -103
- tictacsync/yaltc.py +17 -20
- {tictacsync-0.97a0.dist-info → tictacsync-0.98a0.dist-info}/METADATA +4 -3
- tictacsync-0.98a0.dist-info/RECORD +16 -0
- tictacsync-0.97a0.dist-info/RECORD +0 -16
- {tictacsync-0.97a0.dist-info → tictacsync-0.98a0.dist-info}/LICENSE +0 -0
- {tictacsync-0.97a0.dist-info → tictacsync-0.98a0.dist-info}/WHEEL +0 -0
- {tictacsync-0.97a0.dist-info → tictacsync-0.98a0.dist-info}/entry_points.txt +0 -0
- {tictacsync-0.97a0.dist-info → tictacsync-0.98a0.dist-info}/top_level.txt +0 -0
tictacsync/timeline.py
CHANGED
|
@@ -9,8 +9,9 @@ from rich import print
|
|
|
9
9
|
from itertools import groupby
|
|
10
10
|
# import opentimelineio as otio
|
|
11
11
|
from datetime import timedelta
|
|
12
|
-
import
|
|
12
|
+
import shutil, os, sys, stat
|
|
13
13
|
from subprocess import Popen, PIPE
|
|
14
|
+
from pprint import pformat
|
|
14
15
|
|
|
15
16
|
from inspect import currentframe, getframeinfo
|
|
16
17
|
try:
|
|
@@ -20,10 +21,26 @@ except:
|
|
|
20
21
|
import yaltc
|
|
21
22
|
import device_scanner
|
|
22
23
|
|
|
23
|
-
CLUSTER_GAP =
|
|
24
|
+
CLUSTER_GAP = 2 # secs between multicam clusters
|
|
24
25
|
DEL_TEMP = False
|
|
25
26
|
DB_OSX_NORM = -6 #dB
|
|
26
27
|
OUT_DIR_DEFAULT = 'SyncedMedia'
|
|
28
|
+
MCCDIR = 'SyncedMulticamClips'
|
|
29
|
+
|
|
30
|
+
# utility to lock ISO audio files
|
|
31
|
+
def remove_write_permissions(path):
|
|
32
|
+
"""Remove write permissions from this path, while keeping all other permissions intact.
|
|
33
|
+
|
|
34
|
+
Params:
|
|
35
|
+
path: The path whose permissions to alter.
|
|
36
|
+
"""
|
|
37
|
+
NO_USER_WRITING = ~stat.S_IWUSR
|
|
38
|
+
NO_GROUP_WRITING = ~stat.S_IWGRP
|
|
39
|
+
NO_OTHER_WRITING = ~stat.S_IWOTH
|
|
40
|
+
NO_WRITING = NO_USER_WRITING & NO_GROUP_WRITING & NO_OTHER_WRITING
|
|
41
|
+
|
|
42
|
+
current_permissions = stat.S_IMODE(os.lstat(path).st_mode)
|
|
43
|
+
os.chmod(path, current_permissions & NO_WRITING)
|
|
27
44
|
|
|
28
45
|
# utility for accessing pathnames
|
|
29
46
|
def _pathname(tempfile_or_path) -> str:
|
|
@@ -64,6 +81,12 @@ def _extr_channel(source, dest, channel):
|
|
|
64
81
|
status = sox_transform.build(str(source), str(dest))
|
|
65
82
|
logger.debug('sox status %s'%status)
|
|
66
83
|
|
|
84
|
+
def _same(aList):
|
|
85
|
+
return aList.count(aList[0]) == len(aList)
|
|
86
|
+
|
|
87
|
+
def _flatten(xss):
|
|
88
|
+
return [x for xs in xss for x in xs]
|
|
89
|
+
|
|
67
90
|
def _sox_keep(audio_file, kept_channels: list) -> tempfile.NamedTemporaryFile:
|
|
68
91
|
"""
|
|
69
92
|
Returns a NamedTemporaryFile containing the selected kept_channels
|
|
@@ -102,7 +125,7 @@ def _sox_keep(audio_file, kept_channels: list) -> tempfile.NamedTemporaryFile:
|
|
|
102
125
|
stderr).decode('utf-8'))
|
|
103
126
|
return output_tempfile
|
|
104
127
|
|
|
105
|
-
def
|
|
128
|
+
def _sox_split_channels(multi_chan_audio:Path) -> list:
|
|
106
129
|
nchan = sox.file_info.channels(_pathname(multi_chan_audio))
|
|
107
130
|
source = _pathname(multi_chan_audio)
|
|
108
131
|
paths = []
|
|
@@ -383,14 +406,14 @@ class AudioStitcherVideoMerger:
|
|
|
383
406
|
def _get_audio_devices(self):
|
|
384
407
|
devices = set([r.device for r in self.get_matched_audio_recs()])
|
|
385
408
|
logger.debug('get_matched_audio_recs: %s'%
|
|
386
|
-
|
|
409
|
+
pformat(self.get_matched_audio_recs()))
|
|
387
410
|
logger.debug('devices %s'%devices)
|
|
388
411
|
return devices
|
|
389
412
|
|
|
390
413
|
def _get_secondary_audio_devices(self):
|
|
391
414
|
devices = set([r.device for r in self.get_matched_audio_recs()])
|
|
392
415
|
logger.debug('get_matched_audio_recs: %s'%
|
|
393
|
-
|
|
416
|
+
pformat(self.get_matched_audio_recs()))
|
|
394
417
|
logger.debug('devices %s'%devices)
|
|
395
418
|
return devices
|
|
396
419
|
|
|
@@ -641,7 +664,7 @@ class AudioStitcherVideoMerger:
|
|
|
641
664
|
Returns nothing, output is written to filesystem as below.
|
|
642
665
|
ISOs subfolders structure when user invokes the --isos flag:
|
|
643
666
|
|
|
644
|
-
SyncedMedia/ (or
|
|
667
|
+
SyncedMedia/ (or anchor_dir)
|
|
645
668
|
|
|
646
669
|
leftCAM/
|
|
647
670
|
|
|
@@ -689,24 +712,16 @@ class AudioStitcherVideoMerger:
|
|
|
689
712
|
synced_clip_dir = synced_clip_file.parent
|
|
690
713
|
# build ISOs subfolders structure, see comment string below
|
|
691
714
|
video_stem_WO_suffix = synced_clip_file.stem
|
|
692
|
-
# video_stem_WO_suffix = synced_clip_file.stem.split('.')[0]
|
|
693
|
-
# OUT_DIR_DEFAULT, D2 = ISOsDIR.split('/')
|
|
694
715
|
ISOdir = synced_clip_dir/(video_stem_WO_suffix + '_ISO')
|
|
695
716
|
os.makedirs(ISOdir, exist_ok=True)
|
|
696
717
|
logger.debug('edited_audio_all_devices %s'%edited_audio_all_devices)
|
|
697
718
|
logger.debug('ISOdir %s'%ISOdir)
|
|
698
|
-
# ISO_multi_chan = ISOdir / 'ISO_multi_chan.wav'
|
|
699
|
-
# logger.debug('temp file: %s'%(ISO_multi_chan))
|
|
700
|
-
# logger.debug('will split audio to %s'%(ISOdir))
|
|
701
719
|
for name, mono_tmpfl in edited_audio_all_devices:
|
|
702
|
-
# pad(start_duration: float = 0.0, end_duration: float = 0.0)[source]
|
|
703
720
|
destination = ISOdir/('%s.wav'%name)
|
|
704
721
|
mono_tmpfl_trimpad = _fit_length(mono_tmpfl)
|
|
705
722
|
shutil.copy(_pathname(mono_tmpfl_trimpad), destination)
|
|
723
|
+
remove_write_permissions(destination)
|
|
706
724
|
logger.debug('destination:%s'%destination)
|
|
707
|
-
# # mixNnormed = _sox_mix_files(tempfiles)
|
|
708
|
-
# # print('516', _pathname(mixNnormed))
|
|
709
|
-
# os.remove(ISO_multi_chan)
|
|
710
725
|
|
|
711
726
|
def _get_device_mix(self, device, multichan_tmpfl) -> tempfile.NamedTemporaryFile:
|
|
712
727
|
"""
|
|
@@ -789,7 +804,7 @@ class AudioStitcherVideoMerger:
|
|
|
789
804
|
# all, but here remove '0' and TTC tracks from mix
|
|
790
805
|
all_channels = list(range(1, device.n_chan + 1)) # sox not ZBIDX
|
|
791
806
|
to_remove = device.tracks.unused + [device.ttc+1]# unused is sox idx
|
|
792
|
-
logger.debug('multitrack but no
|
|
807
|
+
logger.debug('multitrack but no mix, mixing mono %s except # %s (sox #)'%
|
|
793
808
|
(all_channels, to_remove))
|
|
794
809
|
sox_kept_channels = [i for i in all_channels
|
|
795
810
|
if i not in to_remove]
|
|
@@ -851,26 +866,25 @@ class AudioStitcherVideoMerger:
|
|
|
851
866
|
stereo_files = mic_stereo_files + new_stereo_files
|
|
852
867
|
return _sox_mix_files(stereo_files)
|
|
853
868
|
|
|
854
|
-
def build_audio_and_write_merged_media(self, top_dir,
|
|
855
|
-
|
|
856
|
-
asked_ISOs, audio_REC_only):
|
|
869
|
+
def build_audio_and_write_merged_media(self, top_dir,
|
|
870
|
+
dont_write_cam_folder, asked_ISOs, audio_REC_only):
|
|
857
871
|
# simply bifurcates depending if ref media is video (prob 99%)
|
|
858
872
|
# (then audio_REC_only == False)
|
|
859
|
-
# or if ref media is audio (no camera detected)
|
|
873
|
+
# or if ref media is audio (no camera detected, 1% of cases)
|
|
860
874
|
# (with audio_REC_only == True)
|
|
861
875
|
if not audio_REC_only:
|
|
862
|
-
self._build_audio_and_write_video(top_dir,
|
|
863
|
-
|
|
876
|
+
self._build_audio_and_write_video(top_dir,
|
|
877
|
+
dont_write_cam_folder, asked_ISOs)
|
|
864
878
|
else:
|
|
865
|
-
self._build_and_write_audio(top_dir,
|
|
879
|
+
self._build_and_write_audio(top_dir, anchor_dir)
|
|
866
880
|
|
|
867
|
-
def _build_and_write_audio(self, top_dir,
|
|
881
|
+
def _build_and_write_audio(self, top_dir, anchor_dir):
|
|
868
882
|
"""
|
|
869
883
|
This is called when only audio recorders were found (no cam).
|
|
870
884
|
|
|
871
885
|
top_dir: Path, directory where media were looked for
|
|
872
886
|
|
|
873
|
-
|
|
887
|
+
anchor_dir: str for optional folder specified as CLI argument, if
|
|
874
888
|
value is None, fall back to OUT_DIR_DEFAULT
|
|
875
889
|
|
|
876
890
|
For each audio devices found overlapping self.ref_audio: pad, trim
|
|
@@ -889,16 +903,16 @@ class AudioStitcherVideoMerger:
|
|
|
889
903
|
self.ref_audio.device.name))
|
|
890
904
|
# eg, suppose the user called tictacsync with 'mondayPM' as top folder
|
|
891
905
|
# to scan for dailies (and 'somefolder' for output):
|
|
892
|
-
if
|
|
906
|
+
if anchor_dir == None:
|
|
893
907
|
synced_clip_dir = Path(top_dir)/OUT_DIR_DEFAULT # = mondayPM/SyncedMedia
|
|
894
908
|
else:
|
|
895
|
-
synced_clip_dir = Path(
|
|
909
|
+
synced_clip_dir = Path(anchor_dir)/Path(top_dir).name # = somefolder/mondayPM
|
|
896
910
|
self.synced_clip_dir = synced_clip_dir
|
|
897
911
|
os.makedirs(synced_clip_dir, exist_ok=True)
|
|
898
912
|
logger.debug('synced_clip_dir is: %s'%synced_clip_dir)
|
|
899
913
|
synced_clip_file = synced_clip_dir/\
|
|
900
914
|
Path(self.videoclip.new_rec_name).name
|
|
901
|
-
logger.debug('editing files for %s'%synced_clip_file)
|
|
915
|
+
logger.debug('editing files for synced_clip_file%s'%synced_clip_file)
|
|
902
916
|
self.ref_audio.final_synced_file = synced_clip_file # relative path
|
|
903
917
|
# Collecting edited audio by device, in (Device, tempfile) pairs:
|
|
904
918
|
# for a given self.ref_audio, each other audio device will have a sequence
|
|
@@ -919,15 +933,15 @@ class AudioStitcherVideoMerger:
|
|
|
919
933
|
# no audio file overlaps for this clip
|
|
920
934
|
return #############################################################
|
|
921
935
|
logger.debug('will output ISO files since no cam')
|
|
922
|
-
devices_and_monofiles = [(device,
|
|
936
|
+
devices_and_monofiles = [(device, _sox_split_channels(multi_chan_audio))
|
|
923
937
|
for device, multi_chan_audio
|
|
924
938
|
in merged_audio_files_by_device]
|
|
925
939
|
# add device and file from self.ref_audio
|
|
926
940
|
new_tuple = (self.ref_audio.device,
|
|
927
|
-
|
|
941
|
+
_sox_split_channels(self.ref_audio.AVpath))
|
|
928
942
|
devices_and_monofiles.append(new_tuple)
|
|
929
943
|
logger.debug('devices_and_monofiles: %s'%
|
|
930
|
-
|
|
944
|
+
pformat(devices_and_monofiles))
|
|
931
945
|
def _trnm(dev, idx): # used in the loop just below
|
|
932
946
|
# generates track name for later if asked_ISOs
|
|
933
947
|
# idx is from 0 to nchan-1 for this device
|
|
@@ -963,17 +977,15 @@ class AudioStitcherVideoMerger:
|
|
|
963
977
|
logger.debug('merged_audio_files_by_device %s'%
|
|
964
978
|
merged_audio_files_by_device)
|
|
965
979
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
write_multicam_structure,
|
|
969
|
-
asked_ISOs):
|
|
980
|
+
def _build_audio_and_write_video(self, top_dir, dont_write_cam_folder,
|
|
981
|
+
asked_ISOs):
|
|
970
982
|
"""
|
|
971
983
|
top_dir: Path, directory where media were looked for
|
|
972
984
|
|
|
973
|
-
|
|
985
|
+
anchor_dir: str for optional folder specified as CLI argument, if
|
|
974
986
|
value is None, fall back to OUT_DIR_DEFAULT
|
|
975
987
|
|
|
976
|
-
|
|
988
|
+
dont_write_cam_folder: True if needs to bypass writing multicam folders
|
|
977
989
|
|
|
978
990
|
asked_ISOs: bool flag specified as CLI argument
|
|
979
991
|
|
|
@@ -987,25 +999,28 @@ class AudioStitcherVideoMerger:
|
|
|
987
999
|
|
|
988
1000
|
Sets AudioStitcherVideoMerger.final_synced_file on completion
|
|
989
1001
|
"""
|
|
990
|
-
logger.debug(' fct args: top_dir
|
|
991
|
-
(top_dir,
|
|
1002
|
+
logger.debug(' fct args: top_dir %s, dont_write_cam_folder %s, asked_ISOs %s'%
|
|
1003
|
+
(top_dir, dont_write_cam_folder, asked_ISOs))
|
|
992
1004
|
logger.debug('device for rec %s: %s'%(self.videoclip,
|
|
993
1005
|
self.videoclip.device))
|
|
994
1006
|
# eg, suppose the user called tictacsync with 'mondayPM' as top folder
|
|
995
1007
|
# to scan for dailies (and 'somefolder' for output):
|
|
996
|
-
if
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1008
|
+
# if anchor_dir == None:
|
|
1009
|
+
# then "alongside mode" i.e., in a folder neighboring the video clip
|
|
1010
|
+
# WAS: synced_clip_dir = Path(top_dir)/OUT_DIR_DEFAULT # = mondayPM/SyncedMedia
|
|
1011
|
+
logger.debug('"alongside mode" for clip: %s'%self.videoclip.AVpath)
|
|
1012
|
+
synced_clip_dir = self.videoclip.AVpath.parent/OUT_DIR_DEFAULT
|
|
1013
|
+
# else: # [TODO: replace original clip in a mirrored file structure]
|
|
1014
|
+
# logger.debug('mirrored mode for clip: %s'%self.videoclip.AVpath)
|
|
1015
|
+
# synced_clip_dir = Path(anchor_dir)/Path(top_dir).name # = somefolder/mondayPM
|
|
1016
|
+
# if write_multicam_structure:
|
|
1017
|
+
# device_name = self.videoclip.device.name
|
|
1018
|
+
# synced_clip_dir = synced_clip_dir/device_name # = synced_clip_dir/ZOOM
|
|
1003
1019
|
self.synced_clip_dir = synced_clip_dir
|
|
1004
1020
|
os.makedirs(synced_clip_dir, exist_ok=True)
|
|
1005
1021
|
logger.debug('synced_clip_dir is: %s'%synced_clip_dir)
|
|
1006
|
-
synced_clip_file = synced_clip_dir
|
|
1007
|
-
|
|
1008
|
-
logger.debug('editing files for %s'%synced_clip_file)
|
|
1022
|
+
synced_clip_file = synced_clip_dir/self.videoclip.AVpath.name
|
|
1023
|
+
logger.debug('editing files for synced_clip_file %s'%synced_clip_file)
|
|
1009
1024
|
self.videoclip.final_synced_file = synced_clip_file # relative path
|
|
1010
1025
|
# Collecting edited audio by device, in (Device, tempfiles) pairs:
|
|
1011
1026
|
# for a given self.videoclip, each audio device will have a sequence
|
|
@@ -1069,7 +1084,7 @@ class AudioStitcherVideoMerger:
|
|
|
1069
1084
|
mix_of_device_mixes = _sox_mix_files(device_mixes)
|
|
1070
1085
|
logger.debug('will merge with %s'%(_pathname(mix_of_device_mixes)))
|
|
1071
1086
|
self.videoclip.synced_audio = mix_of_device_mixes
|
|
1072
|
-
logger.debug('mix_of_device_mixes
|
|
1087
|
+
logger.debug('mix_of_device_mixes (final mix) has %i channels'%
|
|
1073
1088
|
sox.file_info.channels(_pathname(mix_of_device_mixes)))
|
|
1074
1089
|
self._merge_audio_and_video()
|
|
1075
1090
|
# devices_and_monofiles is list of (device, [monofiles])
|
|
@@ -1079,18 +1094,20 @@ class AudioStitcherVideoMerger:
|
|
|
1079
1094
|
# devices_and_monofiles:
|
|
1080
1095
|
if asked_ISOs:
|
|
1081
1096
|
logger.debug('will output ISO files...')
|
|
1082
|
-
devices_and_monofiles = [(device,
|
|
1097
|
+
devices_and_monofiles = [(device, _sox_split_channels(multi_chan_audio))
|
|
1083
1098
|
for device, multi_chan_audio
|
|
1084
1099
|
in merged_audio_files_by_device]
|
|
1085
1100
|
logger.debug('devices_and_monofiles: %s'%
|
|
1086
|
-
|
|
1087
|
-
def
|
|
1101
|
+
pformat(devices_and_monofiles))
|
|
1102
|
+
def _build_from_tracks_txt(dev, idx):
|
|
1103
|
+
# used in the loop just below
|
|
1088
1104
|
# generates track name for later if asked_ISOs
|
|
1089
1105
|
# idx is from 0 to nchan-1 for this device
|
|
1090
1106
|
if dev.tracks == None:
|
|
1107
|
+
# no tracks.txt was found so use ascending numbers for name
|
|
1091
1108
|
chan_name = 'chan%s'%str(idx+1).zfill(2)
|
|
1092
1109
|
else:
|
|
1093
|
-
# sanitize
|
|
1110
|
+
# sanitize names in tracks.txt
|
|
1094
1111
|
symbols = set(r"""`~!@#$%^&*()_-+={[}}|\:;"'<,>.?/""")
|
|
1095
1112
|
chan_name = dev.tracks.rawtrx[idx]
|
|
1096
1113
|
logger.debug('raw chan_name %s'%chan_name)
|
|
@@ -1110,12 +1127,12 @@ class AudioStitcherVideoMerger:
|
|
|
1110
1127
|
names_audio_tempfiles = []
|
|
1111
1128
|
for dev, mono_tmpfiles_list in devices_and_monofiles:
|
|
1112
1129
|
for idx, monotf in enumerate(mono_tmpfiles_list):
|
|
1113
|
-
track_name =
|
|
1130
|
+
track_name = _build_from_tracks_txt(dev, idx)
|
|
1114
1131
|
logger.debug('track_name %s'%track_name)
|
|
1115
1132
|
if track_name[0] == '0': # muted, skip
|
|
1116
1133
|
continue
|
|
1117
1134
|
names_audio_tempfiles.append((track_name, monotf))
|
|
1118
|
-
logger.debug('names_audio_tempfiles %s'%names_audio_tempfiles)
|
|
1135
|
+
logger.debug('names_audio_tempfiles %s'%pformat(names_audio_tempfiles))
|
|
1119
1136
|
self._write_ISOs(names_audio_tempfiles)
|
|
1120
1137
|
logger.debug('merged_audio_files_by_device %s'%
|
|
1121
1138
|
merged_audio_files_by_device)
|
|
@@ -1233,6 +1250,10 @@ class Matcher:
|
|
|
1233
1250
|
AudioStitcherVideoMerger object. An audio_stitch doesn't extend
|
|
1234
1251
|
beyond the corresponding video start and end times.
|
|
1235
1252
|
|
|
1253
|
+
multicam_clips_clusters : list
|
|
1254
|
+
of {'end': t1, 'start': t2, 'vids': [r1,r3]} where r1 and r3
|
|
1255
|
+
are overlapping.
|
|
1256
|
+
|
|
1236
1257
|
"""
|
|
1237
1258
|
|
|
1238
1259
|
def __init__(self, recordings_list):
|
|
@@ -1246,20 +1267,20 @@ class Matcher:
|
|
|
1246
1267
|
self.recordings = recordings_list
|
|
1247
1268
|
self.mergers = []
|
|
1248
1269
|
|
|
1249
|
-
def _rename_all_recs(self):
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1270
|
+
# def _rename_all_recs(self):
|
|
1271
|
+
# """
|
|
1272
|
+
# Add _synced to filenames of synced video files. Change stored name only:
|
|
1273
|
+
# files have yet to be written to.
|
|
1274
|
+
# """
|
|
1275
|
+
# # match IO_structure:
|
|
1276
|
+
# # case 'foldercam':
|
|
1277
|
+
# for rec in self.recordings:
|
|
1278
|
+
# rec_extension = rec.AVpath.suffix
|
|
1279
|
+
# rel_path_new_name = '%s%s'%(rec.AVpath.stem, rec_extension)
|
|
1280
|
+
# rec.new_rec_name = Path(rel_path_new_name)
|
|
1281
|
+
# logger.debug('for %s new name: %s'%(
|
|
1282
|
+
# _pathname(rec.AVpath),
|
|
1283
|
+
# _pathname(rec.new_rec_name)))
|
|
1263
1284
|
|
|
1264
1285
|
def scan_audio_for_each_videoclip(self):
|
|
1265
1286
|
"""
|
|
@@ -1295,7 +1316,7 @@ class Matcher:
|
|
|
1295
1316
|
self.mergers.append(audio_stitch)
|
|
1296
1317
|
else:
|
|
1297
1318
|
logger.debug('\n nothing\n')
|
|
1298
|
-
print('No overlap found for %s'%videoclip.AVpath.name)
|
|
1319
|
+
print('No overlap found for [gold1]%s[/gold1]'%videoclip.AVpath.name)
|
|
1299
1320
|
del audio_stitch
|
|
1300
1321
|
logger.debug('%i mergers created'%len(self.mergers))
|
|
1301
1322
|
|
|
@@ -1310,49 +1331,29 @@ class Matcher:
|
|
|
1310
1331
|
case4 = R1 < A2 < R2
|
|
1311
1332
|
return case1 or case2 or case3 or case4
|
|
1312
1333
|
|
|
1313
|
-
def
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
For multicam takes, shifts are computed so
|
|
1322
|
-
video clusters are near but dont overlap, ex:
|
|
1323
|
-
|
|
1324
|
-
Cluster 1 Cluster 2
|
|
1325
|
-
1111111111111 2222222222 (cam A)
|
|
1326
|
-
11111111111[inserted gap]222222222 (cam B)
|
|
1327
|
-
|
|
1328
|
-
or
|
|
1329
|
-
1111111111111[inserted 222222 (cam A)
|
|
1330
|
-
1111111 gap]222222222 (cam B)
|
|
1331
|
-
|
|
1332
|
-
argument:
|
|
1333
|
-
CLI_offset (str), option from command-line
|
|
1334
|
-
with_gap (float), the gap duration in seconds
|
|
1335
|
-
|
|
1336
|
-
Returns nothing, changes are done in the video files metadata
|
|
1337
|
-
(each referenced by Recording.final_synced_file)
|
|
1338
|
-
"""
|
|
1334
|
+
def set_up_clusters(self):
|
|
1335
|
+
# builds the list self.multicam_clips_clusters. A list
|
|
1336
|
+
# of {'end': t1, 'start': t2, 'vids': [r1,r3]} where r1 and r3
|
|
1337
|
+
# are overlapping.
|
|
1338
|
+
# if no overlap occurs, length of vid = 1, ex 'vids': [r1]
|
|
1339
|
+
# so not really a cluster in those cases
|
|
1340
|
+
# returns nothing and sets Matcher.multicam_clips_clusters
|
|
1339
1341
|
vids = [m.videoclip for m in self.mergers]
|
|
1340
|
-
logger.debug('vids %s'%vids)
|
|
1341
|
-
if len(vids) == 1:
|
|
1342
|
-
logger.debug('just one take, no gap to shrink')
|
|
1343
|
-
return #############################################################
|
|
1344
1342
|
# INs_and_OUTs contains (time, direction, video) for each video,
|
|
1345
1343
|
# where direction is 'in|out' and video an instance of Recording
|
|
1346
1344
|
INs_and_OUTs = [(vid.get_start_time(), 'in', vid) for vid in vids]
|
|
1347
1345
|
for vid in vids:
|
|
1348
1346
|
INs_and_OUTs.append((vid.get_end_time(), 'out', vid))
|
|
1349
1347
|
INs_and_OUTs = sorted(INs_and_OUTs, key=lambda vtuple: vtuple[0])
|
|
1350
|
-
logger.debug('INs_and_OUTs: %s'%INs_and_OUTs)
|
|
1348
|
+
logger.debug('INs_and_OUTs: %s'%pformat(INs_and_OUTs))
|
|
1351
1349
|
new_cluster = True
|
|
1352
1350
|
current_cluster = {'vids':[]}
|
|
1353
1351
|
N_in, N_out = (0, 0)
|
|
1354
1352
|
# clusters is a list of {'end': t1, 'start': t2, 'vids': [r1,r3]}
|
|
1355
1353
|
clusters = []
|
|
1354
|
+
# a cluster begins (and grows) when a time of type 'in' is encountered
|
|
1355
|
+
# a cluster degrows when a time of type 'out' is encountered and
|
|
1356
|
+
# closes when its size (N_currently_open) reach back to zero
|
|
1356
1357
|
for t, direction, video in INs_and_OUTs:
|
|
1357
1358
|
if new_cluster and direction == 'out':
|
|
1358
1359
|
logger.error('cant begin a cluster with a out time %s'%video)
|
|
@@ -1373,7 +1374,44 @@ Moves clusters at the timelineoffset
|
|
|
1373
1374
|
new_cluster = True
|
|
1374
1375
|
current_cluster = {'vids':[]}
|
|
1375
1376
|
N_in, N_out = (0, 0)
|
|
1376
|
-
logger.debug('clusters: %s'%
|
|
1377
|
+
logger.debug('clusters: %s'%pformat(clusters))
|
|
1378
|
+
self.multicam_clips_clusters = clusters
|
|
1379
|
+
return
|
|
1380
|
+
|
|
1381
|
+
def shrink_gaps_between_takes(self, CLI_offset, with_gap=CLUSTER_GAP):
|
|
1382
|
+
"""
|
|
1383
|
+
for single cam shootings this simply sets the gap between takes,
|
|
1384
|
+
tweaking each vid timecode metadata to distribute them next to each
|
|
1385
|
+
other along NLE timeline.
|
|
1386
|
+
|
|
1387
|
+
Moves clusters at the timelineoffset
|
|
1388
|
+
|
|
1389
|
+
For multicam takes, shifts are computed so
|
|
1390
|
+
video clusters are near but dont overlap, ex:
|
|
1391
|
+
|
|
1392
|
+
***** are inserted gaps
|
|
1393
|
+
|
|
1394
|
+
Cluster 1 Cluster 2
|
|
1395
|
+
1111111111111 2222222222 (cam A)
|
|
1396
|
+
11111111111******222222222 (cam B)
|
|
1397
|
+
|
|
1398
|
+
or
|
|
1399
|
+
11111111111111 222222 (cam A)
|
|
1400
|
+
1111111 ******222222222 (cam B)
|
|
1401
|
+
|
|
1402
|
+
argument:
|
|
1403
|
+
CLI_offset (str), option from command-line
|
|
1404
|
+
with_gap (float), the gap duration in seconds
|
|
1405
|
+
|
|
1406
|
+
Returns nothing, changes are done in the video files metadata
|
|
1407
|
+
(each referenced by Recording.final_synced_file)
|
|
1408
|
+
"""
|
|
1409
|
+
vids = [m.videoclip for m in self.mergers]
|
|
1410
|
+
logger.debug('vids %s'%vids)
|
|
1411
|
+
if len(vids) == 1:
|
|
1412
|
+
logger.debug('just one take, no gap to shrink')
|
|
1413
|
+
return #############################################################
|
|
1414
|
+
clusters = self.multicam_clips_clusters
|
|
1377
1415
|
# if there are N clusters, there are N-1 gaps to evaluate and shorten
|
|
1378
1416
|
# (lengthen?) to a value of with_gap seconds
|
|
1379
1417
|
gaps = [c2['start'] - c1['end'] for c1, c2
|
|
@@ -1414,7 +1452,61 @@ Moves clusters at the timelineoffset
|
|
|
1414
1452
|
vid.write_file_timecode(tc)
|
|
1415
1453
|
return
|
|
1416
1454
|
|
|
1455
|
+
def move_multicam_to_dir(self):
|
|
1456
|
+
# creates a dedicated multicam directory and move clusters there
|
|
1457
|
+
# e.g., for "top/day01/camA/roll02"
|
|
1458
|
+
# ^ at that level
|
|
1459
|
+
# 0 1 2 3
|
|
1460
|
+
# Note: ROLLxx maybe present or not.
|
|
1461
|
+
#
|
|
1462
|
+
# check for consistency: are all clips at the same level from topdir?
|
|
1463
|
+
# Only for video, not audio (which doesnt fill up cards)
|
|
1464
|
+
video_medias = [m for m in self.recordings if m.device.dev_type == 'CAM']
|
|
1465
|
+
video_paths = [m.AVpath.parts for m in video_medias]
|
|
1466
|
+
AV_path_lengths = [len(p) for p in video_paths]
|
|
1467
|
+
# print('AV_path_lengths', AV_path_lengths)
|
|
1468
|
+
if not _same(AV_path_lengths):
|
|
1469
|
+
print('\nError with some clips, check if their locations are consistent (all at the same level in folders).')
|
|
1470
|
+
print('Video synced but could not regroup multicam clips.')
|
|
1471
|
+
sys.exit(0)
|
|
1472
|
+
# now find at which level CAMs reside (maybe there are ROLLxx)
|
|
1473
|
+
CAM_levels = [vm.AVpath.parts.index(vm.device.name)
|
|
1474
|
+
for vm in video_medias]
|
|
1475
|
+
# find for all, should be same
|
|
1476
|
+
if not _same(CAM_levels):
|
|
1477
|
+
print('\nError with some clips, check if their locations are consistent (all at the same level in folders).')
|
|
1478
|
+
print('Video synced but could not regroup multicam clips.')
|
|
1479
|
+
sys.exit(0)
|
|
1480
|
+
# pick first
|
|
1481
|
+
CAM_level, avp = CAM_levels[0], video_medias[0].AVpath
|
|
1482
|
+
logger.debug('CAM_levels: %s for ex \n%s'%(CAM_level, avp))
|
|
1483
|
+
# MCCDIR = 'SyncedMulticamClips'
|
|
1484
|
+
parts_up_a_level = avp.parts[:CAM_level]
|
|
1485
|
+
multicam_dir = Path('').joinpath(*parts_up_a_level)/MCCDIR
|
|
1486
|
+
logger.debug('multicam_dir: %s'%multicam_dir)
|
|
1487
|
+
Path.mkdir(multicam_dir, exist_ok=True)
|
|
1488
|
+
cam_clips = []
|
|
1489
|
+
[cam_clips.append(cl['vids']) for cl in self.multicam_clips_clusters]
|
|
1490
|
+
cam_clips = _flatten(cam_clips)
|
|
1491
|
+
logger.debug('cam_clips: %s'%cam_clips)
|
|
1492
|
+
cam_names = set([r.device.name for r in cam_clips])
|
|
1493
|
+
# create new dirs for each CAM
|
|
1494
|
+
[Path.mkdir(multicam_dir/cam_name, exist_ok=True)
|
|
1495
|
+
for cam_name in cam_names]
|
|
1496
|
+
# move clips there
|
|
1497
|
+
for r in cam_clips:
|
|
1498
|
+
cam = r.device.name
|
|
1499
|
+
clip_name = r.AVpath.name
|
|
1500
|
+
dest = r.final_synced_file.replace(multicam_dir/cam/clip_name)
|
|
1501
|
+
logger.debug('dest: %s'%dest)
|
|
1502
|
+
origin_folder = r.final_synced_file.parent
|
|
1503
|
+
folder_now_empty = len(list(origin_folder.glob('*.*'))) == 0
|
|
1504
|
+
if folder_now_empty:
|
|
1505
|
+
logger.debug('after moving %s, folder is now empty, removing it'%dest)
|
|
1506
|
+
origin_folder.rmdir()
|
|
1507
|
+
print('\nMoved %i multicam clips in %s'%(len(cam_clips), multicam_dir))
|
|
1417
1508
|
|
|
1509
|
+
|
|
1418
1510
|
|
|
1419
1511
|
|
|
1420
1512
|
|
tictacsync/yaltc.py
CHANGED
|
@@ -298,7 +298,7 @@ class Decoder:
|
|
|
298
298
|
'detection level, signal, detected region'.split(','),
|
|
299
299
|
loc='lower right')
|
|
300
300
|
ax.set_title('Finding word + sync pulse')
|
|
301
|
-
plt.xlabel("Position in file (samples)")
|
|
301
|
+
plt.xlabel("Position in file %s (samples)"%self.rec)
|
|
302
302
|
plt.show()
|
|
303
303
|
|
|
304
304
|
def get_time_in_sound_extract(self):
|
|
@@ -347,6 +347,14 @@ class Decoder:
|
|
|
347
347
|
sync_pos_in_file = self.detected_pulse_position + \
|
|
348
348
|
self.sound_extract_position
|
|
349
349
|
time_values['pulse at'] = sync_pos_in_file
|
|
350
|
+
if not 0 <= time_values['seconds'] <= 59:
|
|
351
|
+
return None
|
|
352
|
+
if not 0 <= time_values['minutes'] <= 59:
|
|
353
|
+
return None
|
|
354
|
+
if not 0 <= time_values['hours'] <= 23:
|
|
355
|
+
return None
|
|
356
|
+
if not 1 <= time_values['month'] <= 12:
|
|
357
|
+
return None
|
|
350
358
|
return time_values
|
|
351
359
|
|
|
352
360
|
def _detect_sync_pulse_position(self):
|
|
@@ -468,11 +476,11 @@ class Decoder:
|
|
|
468
476
|
logger.debug('%.2f ms (max value %.2f)'%(1e3*gap/self.samplerate,
|
|
469
477
|
1e3*TEENSY_MAX_LAG))
|
|
470
478
|
logger.debug('relative audio_block gap %.2f'%(relative_gap))
|
|
471
|
-
if relative_gap > 1:
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
479
|
+
# if relative_gap > 1: # dont tell: simply fail and try elsewhere
|
|
480
|
+
# print('Warning: gap between spike and word is too big for %s'%self.rec)
|
|
481
|
+
# print('Audio update() gap between sync pulse and word start: ')
|
|
482
|
+
# print('%.2f ms (max value %.2f)'%(1e3*gap/self.samplerate,
|
|
483
|
+
# 1e3*TEENSY_MAX_LAG))
|
|
476
484
|
symbol_width_samples_theor = self.samplerate*SYMBOL_LENGTH*1e-3
|
|
477
485
|
symbol_width_samples_eff = self.effective_word_duration * \
|
|
478
486
|
self.samplerate/(N_SYMBOLS - 1)
|
|
@@ -498,7 +506,7 @@ class Decoder:
|
|
|
498
506
|
transform=xt,
|
|
499
507
|
linewidth=0.6, colors='green')
|
|
500
508
|
ax.set_title('Slicing the 34 bits word:')
|
|
501
|
-
plt.xlabel("Position in file (samples)")
|
|
509
|
+
plt.xlabel("Position in file %s (samples)"%self.rec)
|
|
502
510
|
ax.vlines(start, 0, 1,
|
|
503
511
|
transform=xt,
|
|
504
512
|
linewidth=0.6, colors='red')
|
|
@@ -532,10 +540,6 @@ class Recording:
|
|
|
532
540
|
device : Device
|
|
533
541
|
identifies the device used for the recording, set in __init__()
|
|
534
542
|
|
|
535
|
-
new_rec_name : str
|
|
536
|
-
built using the device name, ex: "CAM_A001"
|
|
537
|
-
set by Timeline._rename_all_recs()
|
|
538
|
-
|
|
539
543
|
probe : dict
|
|
540
544
|
returned value of ffmpeg.probe(self.AVpath)
|
|
541
545
|
|
|
@@ -648,7 +652,7 @@ class Recording:
|
|
|
648
652
|
# self.valid_sound = None
|
|
649
653
|
self.final_synced_file = None
|
|
650
654
|
self.synced_audio = None
|
|
651
|
-
self.new_rec_name = media.path.name
|
|
655
|
+
# self.new_rec_name = media.path.name
|
|
652
656
|
self.do_plots = do_plots
|
|
653
657
|
logger.debug('__init__ Recording object %s'%self.__repr__())
|
|
654
658
|
logger.debug(' in directory %s'%self.AVpath.parent)
|
|
@@ -718,7 +722,7 @@ class Recording:
|
|
|
718
722
|
|
|
719
723
|
def __repr__(self):
|
|
720
724
|
# return 'Recording of %s'%_pathname(self.new_rec_name)
|
|
721
|
-
return _pathname(self.
|
|
725
|
+
return _pathname(self.AVpath)
|
|
722
726
|
|
|
723
727
|
def _check_for_camera_error_correction(self):
|
|
724
728
|
# look for a file number
|
|
@@ -1220,13 +1224,6 @@ class Recording:
|
|
|
1220
1224
|
)
|
|
1221
1225
|
return self
|
|
1222
1226
|
|
|
1223
|
-
def seems_to_have_TicTacCode_at_beginning(self):
|
|
1224
|
-
if self.probe is None:
|
|
1225
|
-
return False #######################################################
|
|
1226
|
-
self._find_TicTacCode(TRIAL_TIMES[0][0],
|
|
1227
|
-
SOUND_EXTRACT_LENGTH)
|
|
1228
|
-
return self.TicTacCode_channel is not None
|
|
1229
|
-
|
|
1230
1227
|
def does_overlap_with_time(self, time):
|
|
1231
1228
|
A1, A2 = self.get_start_time(), self.get_end_time()
|
|
1232
1229
|
# R1, R2 = rec.get_start_time(), rec.get_end_time()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: tictacsync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.98a0
|
|
4
4
|
Summary: command for syncing audio video recordings
|
|
5
5
|
Home-page: https://tictacsync.org/
|
|
6
6
|
Author: Raymond Lutz
|
|
@@ -29,6 +29,7 @@ Requires-Dist: rich >=10.12.0
|
|
|
29
29
|
Requires-Dist: lmfit
|
|
30
30
|
Requires-Dist: scikit-image
|
|
31
31
|
Requires-Dist: scipy >=1.10.1
|
|
32
|
+
Requires-Dist: platformdirs
|
|
32
33
|
|
|
33
34
|
# tictacsync
|
|
34
35
|
|
|
@@ -67,7 +68,7 @@ Then pip install the syncing program:
|
|
|
67
68
|
This should install python dependencies _and_ the `tictacsync` command.
|
|
68
69
|
## Usage
|
|
69
70
|
|
|
70
|
-
Download multiple sample files [here](https://nuage.lutz.quebec/s/
|
|
71
|
+
Download multiple sample files [here](https://nuage.lutz.quebec/s/NpjzXH5R7DrQEWS/download/dailies.zip) (625 MB, sorry) unzip and run:
|
|
71
72
|
|
|
72
73
|
> tictacsync dailies/loose
|
|
73
74
|
The program `tictacsync` will recursively scan the directory given as argument, find all audio that coincide with any video and merge them into a subfolder named `SyncedMedia`. When the argument is an unique media file (not a directory), no syncing will occur but the decoded starting time will be printed to stdout:
|
|
@@ -96,7 +97,7 @@ For a one line output (or to suppress the progress bars) use the `--terse` flag:
|
|
|
96
97
|
|
|
97
98
|
Specifying `--isos` produces _synced_ ISO audio files: for each synced \<video-clip\> a directory named `<video-clip>_ISO` will contain a set of ISO audio files each of exact same length, padded or trimmed to coincide with the video start. After re-editing and re-mixing in your DAW of choice a `remergemix` command will resync the new audio with the video and _the new sound track will be updated on your NLE timeline_, _automagically_ on some NLEs or on command for [Davinci Resolve](https://www.niwa.nu/dr-scripts/).
|
|
98
99
|
|
|
99
|
-
> tictacsync --isos dailies/
|
|
100
|
+
> tictacsync --isos dailies/structured
|
|
100
101
|
#### `-p`
|
|
101
102
|
|
|
102
103
|
When called with the `-p` flag, zoomable plots will be produced for diagnostic purpose (close the plotting window for the 2nd one) and the decoded starting time will be output to stdin:
|