diff --git a/_build/InnoSetup/assets/SmallWizardImage.bmp b/_build/InnoSetup/assets/SmallWizardImage.bmp old mode 100755 new mode 100644 diff --git a/_build/InnoSetup/assets/WizModernImage.bmp b/_build/InnoSetup/assets/WizModernImage.bmp old mode 100755 new mode 100644 diff --git a/_build/InnoSetup/assets/WizModernImage.psd b/_build/InnoSetup/assets/WizModernImage.psd old mode 100755 new mode 100644 diff --git a/_packaging/snap/snapcraft.yaml b/_packaging/snap/snapcraft.yaml old mode 100755 new mode 100644 diff --git a/vidcutter/images/_originals/clip-index-header.psd b/vidcutter/images/_originals/clip-index-header.psd old mode 100755 new mode 100644 diff --git a/vidcutter/images/_originals/dialog-backdrop-02.psd b/vidcutter/images/_originals/dialog-backdrop-02.psd old mode 100755 new mode 100644 diff --git a/vidcutter/images/_originals/dialog-backdrop.psd b/vidcutter/images/_originals/dialog-backdrop.psd old mode 100755 new mode 100644 diff --git a/vidcutter/images/_originals/player-buttons.psd b/vidcutter/images/_originals/player-buttons.psd old mode 100755 new mode 100644 diff --git a/vidcutter/images/_originals/startup-backdrop.psd b/vidcutter/images/_originals/startup-backdrop.psd old mode 100755 new mode 100644 diff --git a/vidcutter/images/_originals/vidcutter-dmg.png b/vidcutter/images/_originals/vidcutter-dmg.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/arrow-left-on.png b/vidcutter/images/arrow-left-on.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/arrow-left.png b/vidcutter/images/arrow-left.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/arrow-right-on.png b/vidcutter/images/arrow-right-on.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/arrow-right.png b/vidcutter/images/arrow-right.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/dark/info-active.png b/vidcutter/images/dark/info-active.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/dark/info-hover.png b/vidcutter/images/dark/info-hover.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/dark/info.png b/vidcutter/images/dark/info.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/dialog-backdrop.png b/vidcutter/images/dialog-backdrop.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/filmstrip-thumbs.png b/vidcutter/images/filmstrip-thumbs.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/handle-nothumbs.png b/vidcutter/images/handle-nothumbs.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/handle.png b/vidcutter/images/handle.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/light/info-active.png b/vidcutter/images/light/info-active.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/light/info-hover.png b/vidcutter/images/light/info-hover.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/light/info.png b/vidcutter/images/light/info.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/light/toolbar-end.png b/vidcutter/images/light/toolbar-end.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/light/toolbar-open.png b/vidcutter/images/light/toolbar-open.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/light/toolbar-pause.png b/vidcutter/images/light/toolbar-pause.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/light/toolbar-play.png b/vidcutter/images/light/toolbar-play.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/light/toolbar-save.png b/vidcutter/images/light/toolbar-save.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/light/toolbar-start.png b/vidcutter/images/light/toolbar-start.png old mode 100755 new mode 100644 diff --git a/vidcutter/images/startup-backdrop.jpg b/vidcutter/images/startup-backdrop.jpg old mode 100755 new mode 100644 diff --git a/vidcutter/libs/config.py b/vidcutter/libs/config.py index 0d0a0931..fa5f61f9 100644 --- a/vidcutter/libs/config.py +++ b/vidcutter/libs/config.py @@ -23,6 +23,7 @@ ####################################################################### from enum import Enum +from typing import Dict, List from PyQt5.QtCore import QSize @@ -40,41 +41,41 @@ def filter_settings() -> Munch: ) @property - def thumbnails(self) -> dict: + def thumbnails(self) -> Dict[str, QSize]: return {'INDEX': QSize(100, 70), 'TIMELINE': QSize(105, 60)} @property - def video_codecs(self) -> list: + def video_codecs(self) -> List[str]: return ['flv', 'h263', 'libvpx', 'libx264', 'libx265', 'libxvid', 'mpeg2video', 'mpeg4', 'msmpeg4', 'wmv2'] @property - def audio_codecs(self) -> list: + def audio_codecs(self) -> List[str]: return ['aac', 'ac3', 'libfaac', 'libmp3lame', 'libvo_aacenc', 'libvorbis', 'mp2', 'wmav2'] @property - def formats(self) -> list: + def formats(self) -> List[str]: return [ '3g2', '3gp', 'aac', 'ac3', 'avi', 'dv', 'flac', 'flv', 'm4a', 'm4v', 'mka', 'mkv', 'mov', 'mp3', 'mp4', 'mpg', 'ogg', 'vob', 'wav', 'webm', 'wma', 'wmv' ] @property - def mpeg_formats(self) -> list: + def mpeg_formats(self) -> List[str]: return [ 'h264', 'hevc', 'mpeg4', 'divx', 'xvid', 'webm', 'ivf', 'vp9', 'mpeg2video', 'mpg2', 'mp2', 'mp3', 'aac' ] @property - def encoding(self) -> dict: + def encoding(self) -> Dict[str, List[str]]: return { - 'hevc': 'libx265 -tune zerolatency -preset ultrafast -x265-params crf=23 -qp 4 -flags +cgop', - 'h264': 'libx264 -tune film -preset ultrafast -x264-params crf=23 -qp 0 -flags +cgop', - 'vp9': 'libvpx-vp9 -deadline best -quality best' + 'hevc': ['libx265', '-tune', 'zerolatency', '-preset', 'ultrafast', '-x265-params', 'crf=23', '-qp', '4', '-flags', '+cgop'], + 'h264': ['libx264', '-tune', 'film', '-preset', 'ultrafast', '-x264-params', 'crf=18', '-qp', '0', '-flags', '+cgop'], + 'vp9': ['libvpx-vp9', '-deadline', 'best', '-quality', 'best'], } @property - def binaries(self) -> dict: + def binaries(self) -> Dict[str, Dict[str, List[str]]]: return { 'nt': { # Windows 'ffmpeg': ['ffmpeg.exe'], @@ -89,7 +90,7 @@ def binaries(self) -> dict: } @property - def filters(self) -> dict: + def filters(self) -> Dict[str, List[str]]: return { 'all': [ '3g2', '3gp', 'amv', 'asf', 'asx', 'avi', 'bin', 'dat', 'div', 'divx', 'f4v', 'flv', diff --git a/vidcutter/libs/videoservice.py b/vidcutter/libs/videoservice.py index 993fab07..92e89286 100644 --- a/vidcutter/libs/videoservice.py +++ b/vidcutter/libs/videoservice.py @@ -26,11 +26,10 @@ import logging import os import re -import shlex import sys -from bisect import bisect_left +from bisect import bisect_left, bisect_right from functools import partial -from typing import List, Optional, Union +from typing import Dict, List, Optional, Tuple, Union from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QDir, QFileInfo, QObject, QProcess, QProcessEnvironment, QSettings, QSize, QStandardPaths, QStorageInfo, QTemporaryFile, QTime) @@ -91,7 +90,8 @@ def setMedia(self, source: str) -> None: self.logger.info(self.media) for codec_type in Streams.__members__: setattr(self.streams, codec_type.lower(), - [stream for stream in self.media.streams if stream.codec_type == codec_type.lower()]) + [stream for stream in self.media.streams + if hasattr(stream, 'codec_type') and stream.codec_type == codec_type.lower()]) if len(self.streams.video): self.streams.video = self.streams.video[0] # we always assume one video stream per media file else: @@ -112,8 +112,7 @@ def findBackends(settings: QSettings) -> Munch: tools.ffmpeg = settings.value('ffmpeg', None, type=str) tools.ffprobe = settings.value('ffprobe', None, type=str) tools.mediainfo = settings.value('mediainfo', None, type=str) - for tool in list(tools.keys()): - path = tools[tool] + for tool, path in tools.items(): if path is None or not len(path) or not os.path.isfile(path): for exe in VideoService.config.binaries[os.name][tool]: if VideoService.frozen: @@ -175,10 +174,17 @@ def captureFrame(settings: QSettings, source: str, frametime: str, thumbsize: QS imagecap = img.fileName() cmd = VideoService.findBackends(settings).ffmpeg tsize = '{0:d}x{1:d}'.format(thumbsize.width(), thumbsize.height()) - args = '-hide_banner -ss {frametime} -i "{source}" -vframes 1 -s {tsize} -y "{imagecap}"'.format(**locals()) + args = [ + '-hide_banner', + '-ss', frametime, + '-i', source, + '-vframes', '1', + '-s', tsize, + '-y', imagecap, + ] proc = VideoService.initProc() if proc.state() == QProcess.NotRunning: - proc.start(cmd, shlex.split(args)) + proc.start(cmd, args) proc.waitForFinished(-1) if proc.exitStatus() == QProcess.NormalExit and proc.exitCode() == 0: capres = QPixmap(imagecap, 'JPG') @@ -241,7 +247,7 @@ def framesize(self, source: str = None) -> QSize: if source is None and hasattr(self.streams, 'video'): return QSize(int(self.streams.video.width), int(self.streams.video.height)) else: - args = '-i "{}"'.format(source) + args = ['-i', source] result = self.cmdExec(self.backends.ffmpeg, args, True) matches = re.search(r'Stream.*Video:.*[,\s](?P\d+?)x(?P\d+?)[,\s]', result, re.DOTALL).groupdict() @@ -251,39 +257,67 @@ def duration(self, source: str = None) -> QTime: if source is None and hasattr(self.media, 'format') and self.parent is not None: return self.parent.delta2QTime(float(self.media.format.duration)) else: - args = '-i "{}"'.format(source) + args = ['-i', source] result = self.cmdExec(self.backends.ffmpeg, args, True) matches = re.search(r'Duration:\s(?P\d+?):(?P\d+?):(?P\d+\.\d+?),', result, re.DOTALL).groupdict() secs, msecs = matches['secs'].split('.') return QTime(int(matches['hrs']), int(matches['mins']), int(secs), int(msecs)) + def durationSecs(self, source: str = None) -> float: + if source is None and hasattr(self.media, 'format') and self.parent is not None: + return float(self.media.format.duration) + else: + args = ['-i', source] + result = self.cmdExec(self.backends.ffmpeg, args, True) + matches = re.search(r'Duration:\s(?P\d+?):(?P\d+?):(?P\d+\.\d+?),', + result, re.DOTALL).groupdict() + secs, msecs = matches['secs'].split('.') + return int(matches['hrs'])*3600 + int(matches['mins'])*60 + int(secs) + int(msecs) / 1000 + + def qTime2float(self, qTime : QTime) -> float: + return qTime.hour()*3600 + qTime.minute()*60 + qTime.second() + qTime.msec() / 1000 + def codecs(self, source: str = None) -> tuple: if source is None and hasattr(self.streams, 'video'): return self.streams.video.codec_name, self.streams.audio[0].codec_name if len(self.streams.audio) else None else: - args = '-i "{}"'.format(source) + args = ['-i', source] result = self.cmdExec(self.backends.ffmpeg, args, True) - vcodec = re.search(r'Stream.*Video:\s(\w+)', result).group(1) - acodec = re.search(r'Stream.*Audio:\s(\w+)', result).group(1) + match = re.search(r'Stream.*Video:\s(\w+)', result) + if match: + vcodec = match.group(1) + else: + vcodec = None + match = re.search(r'Stream.*Audio:\s(\w+)', result) + if match: + acodec = match.group(1) + else: + acodec = None return vcodec, acodec - def parseMappings(self, allstreams: bool = True) -> str: + def parseMappings(self, allstreams: bool = True) -> List[str]: if not len(self.mappings) or (self.parent is not None and self.parent.hasExternals()): - return '-map 0 ' if allstreams else '' + return ['-map', '0'] if allstreams else [] # if False not in self.mappings: # return '-map 0 ' - output = '' - for stream_id in range(len(self.mappings)): - if self.mappings[stream_id]: - output += '-map 0:{} '.format(stream_id) + output = [] + for stream_id, stream in enumerate(self.mappings): + if stream: + output += ['-map', '0:{}'.format(stream_id)] return output def finalize(self, source: str) -> bool: self.checkDiskSpace(source) source_file, source_ext = os.path.splitext(source) final_filename = '{0}_FINAL{1}'.format(source_file, source_ext) - args = '-v error -i "{}" -map 0 -c copy -y "{}"'.format(source, final_filename) + args = [ + '-v', 'error', + '-i', source, + '-map', '0', + '-c', 'copy', + '-y', final_filename, + ] result = self.cmdExec(self.backends.ffmpeg, args) if result and os.path.exists(final_filename): os.replace(final_filename, source) @@ -291,16 +325,39 @@ def finalize(self, source: str) -> bool: return False def cut(self, source: str, output: str, frametime: str, duration: str, allstreams: bool=True, vcodec: str=None, - run: bool=True) -> Union[bool, str]: + run: bool=True, seektime: str=None) -> Union[bool, List[str]]: self.checkDiskSpace(output) stream_map = self.parseMappings(allstreams) if vcodec is not None: - encode_options = VideoService.config.encoding.get(vcodec, vcodec) - args = '-v 32 -i "{}" -ss {} -t {} -c:v {} -c:a copy -c:s copy {}-avoid_negative_ts 1 ' \ - '-y "{}"'.format(source, frametime, duration, encode_options, stream_map, output) + encode_options = VideoService.config.encoding.get(vcodec, ['copy']) + args = [ + '-v', 'info', + '-i', source, + '-ss', frametime, + '-t', duration, + '-c:v', + ] + encode_options + [ + '-c:a', 'copy', + '-c:s', 'copy', + ] + stream_map + [ + '-avoid_negative_ts', '1', + '-y', output, + ] else: - args = '-v error -ss {} -t {} -i "{}" -c copy {}-avoid_negative_ts 1 -y "{}"' \ - .format(frametime, duration, source, stream_map, output) + args = [ + '-v', 'error', + '-i', source, + '-ss', frametime, + '-t', duration, + '-c', 'copy', + ] + stream_map + [ + '-avoid_negative_ts', '1', + '-y', output, + ] + if seektime is not None: + # insert seektime before '-i' + idx = args.index('-i') + args[idx:idx] = ['-ss', seektime] if run: result = self.cmdExec(self.backends.ffmpeg, args) if not result or os.path.getsize(output) < 1000: @@ -315,7 +372,7 @@ def cut(self, source: str, output: str, frametime: str, duration: str, allstream return True else: if os.getenv('DEBUG', False) or getattr(self.parent, 'verboseLogs', False): - self.logger.info(args) + self.logger.info(' '.join([self.backends.ffmpeg] + args)) return args def smartinit(self, clips: int): @@ -326,29 +383,34 @@ def smartinit(self, clips: int): for index in range(clips) ] - def smartcut(self, index: int, source: str, output: str, start: float, end: float, allstreams: bool = True) -> None: + def smartcut(self, index: int, source: str, output: str, start: float, end: float, keyframes: [], allstreams: bool = True) -> None: output_file, output_ext = os.path.splitext(output) - bisections = self.getGOPbisections(source, start, end) + bisections = self.getGOPbisections(keyframes, start, end) self.smartcut_jobs[index].output = output self.smartcut_jobs[index].allstreams = allstreams # ----------------------[ STEP 1 - start of clip if not starting on a keyframe ]------------------------- - if bisections['start'][1] > bisections['start'][0]: - self.smartcut_jobs[index].files.update(start='{0}_start_{1}{2}' - .format(output_file, '{0:0>2}'.format(index), output_ext)) - startproc = VideoService.initProc(self.backends.ffmpeg, self.smartcheck, os.path.dirname(source)) - startproc.setObjectName('start.{}'.format(index)) - startproc.started.connect(lambda: self.progress.emit(index)) - startproc.setArguments(shlex.split( - self.cut(source=source, - output=self.smartcut_jobs[index].files['start'], - frametime=str(start), - duration=bisections['start'][1] - start, - allstreams=allstreams, - vcodec=self.streams.video.codec_name, - run=False))) - self.smartcut_jobs[index].procs.update(start=startproc) - self.smartcut_jobs[index].results.update(start=False) - startproc.start() + if start == bisections['start'][1]: + vcodec = None # copy + else: + vcodec = self.streams.video.codec_name # re-encode + self.smartcut_jobs[index].files.update(start='{0}_start_{1}{2}' + .format(output_file, '{0:0>2}'.format(index), output_ext)) + startproc = VideoService.initProc(self.backends.ffmpeg, self.smartcheck, os.path.dirname(source)) + startproc.setObjectName('start.{}'.format(index)) + startproc.started.connect(lambda: self.progress.emit(index)) + dur=round(bisections['start'][2] - start, 6) + startproc.setArguments( + self.cut(source=source, + output=self.smartcut_jobs[index].files['start'], + seektime=str(bisections['start'][0]), + frametime=str(round(start - bisections['start'][0], 6)), + duration=str(dur), + allstreams=allstreams, + vcodec=vcodec, + run=False)) + self.smartcut_jobs[index].procs.update(start=startproc) + self.smartcut_jobs[index].results.update(start=False) + startproc.start() # ----------------------[ STEP 2 - cut middle segment of clip ]------------------------- self.smartcut_jobs[index].files.update(middle='{0}_middle_{1}{2}' .format(output_file, '{0:0>2}'.format(index), output_ext)) @@ -357,34 +419,71 @@ def smartcut(self, index: int, source: str, output: str, start: float, end: floa middleproc.setWorkingDirectory(os.path.dirname(self.smartcut_jobs[index].files['middle'])) middleproc.setObjectName('middle.{}'.format(index)) middleproc.started.connect(lambda: self.progress.emit(index)) - middleproc.setArguments(shlex.split( + dur=round(bisections['end'][1] - bisections['start'][2], 6) + middleproc.setArguments( self.cut(source=source, output=self.smartcut_jobs[index].files['middle'], - frametime=bisections['start'][2], - duration=bisections['end'][1] - bisections['start'][2], + seektime=str(bisections['start'][1]), + frametime=str(round(bisections['start'][2] - bisections['start'][1], 6)), + duration=str(dur), allstreams=allstreams, - run=False))) + run=False)) self.smartcut_jobs[index].procs.update(middle=middleproc) self.smartcut_jobs[index].results.update(middle=False) if len(self.smartcut_jobs[index].procs) == 1: middleproc.start() # ----------------------[ STEP 3 - end of clip if not ending on a keyframe ]------------------------- - if bisections['end'][2] > bisections['end'][1]: - self.smartcut_jobs[index].files.update(end='{0}_end_{1}{2}' - .format(output_file, '{0:0>2}'.format(index), output_ext)) - endproc = VideoService.initProc(self.backends.ffmpeg, self.smartcheck, os.path.dirname(source)) - endproc.setObjectName('end.{}'.format(index)) - endproc.started.connect(lambda: self.progress.emit(index)) - endproc.setArguments(shlex.split( - self.cut(source=source, - output=self.smartcut_jobs[index].files['end'], - frametime=bisections['end'][1], - duration=end - bisections['end'][1], - allstreams=allstreams, - vcodec=self.streams.video.codec_name, - run=False))) - self.smartcut_jobs[index].procs.update(end=endproc) - self.smartcut_jobs[index].results.update(end=False) + if end == bisections['end'][2]: + vcodec = None # copy + else: + vcodec = self.streams.video.codec_name # re-encode + self.smartcut_jobs[index].files.update(end='{0}_end_{1}{2}' + .format(output_file, '{0:0>2}'.format(index), output_ext)) + endproc = VideoService.initProc(self.backends.ffmpeg, self.smartcheck, os.path.dirname(source)) + endproc.setObjectName('end.{}'.format(index)) + endproc.started.connect(lambda: self.progress.emit(index)) + dur=round(end - bisections['end'][1], 6) + endproc.setArguments( + self.cut(source=source, + output=self.smartcut_jobs[index].files['end'], + seektime=str(bisections['end'][0]), + frametime=str(round(bisections['end'][1] - bisections['end'][0], 6)), + duration=str(dur), + allstreams=allstreams, + vcodec=vcodec, + run=False)) + self.smartcut_jobs[index].procs.update(end=endproc) + self.smartcut_jobs[index].results.update(end=False) + endproc.start() + + + def forceKeyframes(self, source: str, clipTimes: [], fps: float, output: str) -> None: + # stream_map = self.parseMappings(true) + #eq(n,45)+eq(n,99)+eq(n,154)' + # forcedKeyframes = toFrames(clipTimes) + keyframesExpr = 'expr:' + for index, clip in enumerate(clipTimes): + # if index == 0 and clip[0] != 0: + # keyframesExpr += f'eq(n,{self.qTime2float(clip[0])*fps})' + # else: + keyframesExpr += f'eq(n,{int(self.qTime2float(clip[0])*fps)})' + keyframesExpr += '+' + keyframesExpr += f'eq(n,{int(self.qTime2float(clip[1])*fps)})' + keyframesExpr += '+' + keyframesExpr = keyframesExpr[:-1] + args = [ + '-v', 'info', + '-i', source, + '-map','0', + '-c','copy', + '-force_key_frames', keyframesExpr, + '-y', output, + ] + # print(args) + if os.path.isfile(output): + os.remove(output) + if not self.cmdExec(self.backends.ffmpeg, args): + return result @pyqtSlot(int, QProcess.ExitStatus) def smartcheck(self, code: int, status: QProcess.ExitStatus) -> None: @@ -393,7 +492,7 @@ def smartcheck(self, code: int, status: QProcess.ExitStatus) -> None: index = int(index) self.smartcut_jobs[index].results[name] = (code == 0 and status == QProcess.NormalExit) if os.getenv('DEBUG', False) or getattr(self.parent, 'verboseLogs', False): - self.logger.info('SmartCut progress: {}'.format(self.smartcut_jobs[index].results)) + self.logger.info('SmartCut progress for part {0}: {1}'.format(index, self.smartcut_jobs[index].results)) resultfile = self.smartcut_jobs[index].files.get(name) if not self.smartcut_jobs[index].results[name] or os.path.getsize(resultfile) < 1000: args = self.smartcut_jobs[index].procs[name].arguments() @@ -403,7 +502,10 @@ def smartcheck(self, code: int, status: QProcess.ExitStatus) -> None: args.remove('-map') del args[pos] self.smartcut_jobs[index].procs[name].setArguments(args) - self.smartcut_jobs[index].procs[name].started.disconnect() + try: + self.smartcut_jobs[index].procs[name].started.disconnect() + except TypeError as e: + self.logger.exception('Failed to disconnect SmartCut job {0}.'.format(name), exc_info=True) self.smartcut_jobs[index].procs[name].start() return else: @@ -450,8 +552,8 @@ def smartjoin(self, index: int) -> None: @staticmethod def cleanup(files: List[str]) -> None: try: - [os.remove(file) for file in files] - except FileNotFoundError: + [os.remove(file) for file in files if file] + except (FileNotFoundError, TypeError): pass def join(self, inputs: List[str], output: str, allstreams: bool=True, chapters: Optional[List[str]]=None) -> bool: @@ -459,15 +561,24 @@ def join(self, inputs: List[str], output: str, allstreams: bool=True, chapters: filelist = os.path.normpath(os.path.join(os.path.dirname(inputs[0]), '_vidcutter.list')) with open(filelist, 'w') as f: [f.write('file \'{}\'\n'.format(file.replace("'", "\\'"))) for file in inputs] - stream_map = '-map 0 ' if allstreams else '' + stream_map = ['-map', '0'] if allstreams else [] ffmetadata = None if chapters is not None and len(chapters): ffmetadata = self.getChapterFile(inputs, chapters) - metadata = '-i "{}" -map_metadata 1 '.format(ffmetadata) + metadata = ['-i', ffmetadata, '-map_metadata', '1'] else: - metadata = '' - args = '-v error -f concat -safe 0 -i "{0}" {1}-c copy {2}-y "{3}"' - result = self.cmdExec(self.backends.ffmpeg, args.format(filelist, metadata, stream_map, output)) + metadata = [] + args = [ + '-v', 'error', + '-f', 'concat', + '-safe', '0', + '-i', filelist, + ] + metadata + [ + '-c', 'copy', + ] + stream_map + [ + '-y', output, + ] + result = self.cmdExec(self.backends.ffmpeg, args) os.remove(filelist) if chapters and ffmetadata is not None: os.remove(ffmetadata) @@ -509,14 +620,18 @@ def getBSF(self, source: str) -> tuple: def blackdetect(self, min_duration: float) -> None: try: - args = '-f lavfi -i "movie=\'{0}\',blackdetect=d={1:.1f}[out0]" '.format(os.path.basename(self.source), - min_duration) - args += '-show_entries tags=lavfi.black_start,lavfi.black_end -of default=nw=1 -hide_banner' + args = [ + '-f', 'lavfi', + '-i', 'movie={0},blackdetect=d={1:.1f}[out0]'.format(os.path.basename(self.source), min_duration), + '-show_entries', 'tags=lavfi.black_start,lavfi.black_end', + '-of', 'default=nw=1', + '-hide_banner', + ] if os.getenv('DEBUG', False) or getattr(self.parent, 'verboseLogs', False): - self.logger.info('{0} {1}'.format(self.backends.ffprobe, args)) + self.logger.info('{0} {1}'.format(self.backends.ffprobe, ' '.join(args))) self.filterproc = VideoService.initProc(self.backends.ffprobe, lambda: self.on_blackdetect(min_duration), os.path.dirname(self.source)) - self.filterproc.setArguments(shlex.split(args)) + self.filterproc.setArguments(args) self.filterproc.start() except FileNotFoundError: self.logger.exception('Could not find media file: {}'.format(self.source), exc_info=True) @@ -525,7 +640,7 @@ def blackdetect(self, min_duration: float) -> None: def on_blackdetect(self, min_duration: float) -> None: if self.filterproc.exitStatus() == QProcess.NormalExit and self.filterproc.exitCode() == 0: scenes = [[QTime(0, 0)]] - results = self.filterproc.readAllStandardOutput().data().decode().strip() + results = self.filterproc.readAllStandardOutput().data().decode('iso-8859-1').strip() for line in results.split('\n'): if re.match(r'\[blackdetect @ (.*)\]', line): vals = line.split(']')[1].strip().split(' ') @@ -551,7 +666,13 @@ def killFilterProc(self) -> None: def probe(self, source: str) -> Munch: try: - args = '-v error -show_streams -show_format -of json "{}"'.format(source) + args = [ + '-v', 'error', + '-show_streams', + '-show_format', + '-of', 'json', + source, + ] json_data = self.cmdExec(self.backends.ffprobe, args, output=True, mergechannels=False) return Munch.fromDict(loads(json_data)) except FileNotFoundError: @@ -561,43 +682,94 @@ def probe(self, source: str) -> Munch: self.logger.exception('FFprobe JSON decoding error', exc_info=True) raise - def getKeyframes(self, source: str, formatted_time: bool = False) -> list: + def getKeyframes(self, source: str, formatted_time: bool = False) -> List[Union[str, float]]: + """ + Return a list of key-frame times. + + TODO: change this to return a list of key-frame times for specific sub-streams + Different sub-streams in a container (e.g. in MPEG2-TS) may contain keyframes at different positions. See this comment for more information: + https://github.com/ozmartian/vidcutter/issues/257#issuecomment-569889644 + + :param source: The file name of the media file. + :param formatted_time: If `True`, return times list of strings. Defaults to `False`, which returns times as list of floats. + :returns: a list of key-frame times, eiter formatted as strings or floats. + """ if len(self.keyframes) and source == self.source: return self.keyframes timecode = '0:00:00.000000' if formatted_time else 0 - args = '-v error -show_packets -select_streams v -show_entries packet=pts_time,flags ' \ - '{0}-of csv "{1}"'.format('-sexagesimal ' if formatted_time else '', source) + + # for mpeg2ts movies the pts_time is never N/A + # Note that pts_time and dts_time are equal when both exist and pts_time is N/A for h264 content. + args = [ + '-v', 'error', + '-show_packets', + '-select_streams', 'v', + '-show_entries', 'packet=pts_time,dts_time,flags', + ] + (['-sexagesimal'] if formatted_time else []) + [ + '-of', 'csv', + source, + ] result = self.cmdExec(self.backends.ffprobe, args, output=True, suppresslog=True, mergechannels=False) keyframe_times = [] for line in result.split('\n'): - if line.split(',')[1] != 'N/A': - timecode = line.split(',')[1] - if re.search(',K', line): - if formatted_time: - keyframe_times.append(timecode[:-3]) + parts = line.split(',') + # For some streams, e.g. DVB-T recorded MPEG2TS, the output may look like below. + # in other words, some lines may be empty. Those are the lines following packets with the side_data flag + # ==== ffprobe output BEGIN ====== + # packet,audio,1,95942.464222,95942.464222,K_side_data, + # + # packet,audio,1,95942.488222,95942.488222,K_ + # packet,subtitle,2,95942.550667,95942.550667,K_side_data, + # + # packet,audio,3,95942.464222,95942.464222,K_side_data, + # + # packet,audio,3,95942.488222,95942.488222,K_ + # packet,video,0,95942.910667,95942.910667,__ + # ==== ffprobe output BEGIN ====== + # + # It is therefore important to check for the length of the split + if len(parts) > 1: + if parts[1] != 'N/A': + timecode = parts[1] else: - keyframe_times.append(float(timecode)) - last_keyframe = self.duration().toString('h:mm:ss.zzz') + timecode = parts[2] + if re.search(',K', line): + if formatted_time: + keyframe_times.append(timecode[:-3]) + else: + keyframe_times.append(float(timecode)) + #last_keyframe = self.duration().toString('h:mm:ss.zzz') + last_keyframe = self.durationSecs() if keyframe_times[-1] != last_keyframe: keyframe_times.append(last_keyframe) if source == self.source and not formatted_time: self.keyframes = keyframe_times return keyframe_times - def getGOPbisections(self, source: str, start: float, end: float) -> dict: - keyframes = self.getKeyframes(source) - start_pos = bisect_left(keyframes, start) + def getGOPbisections(self, keyframes: [], start: float, end: float) -> Dict[str, Tuple[float, float, float]]: + """ + Return a mapping of the start and end time to the 3 surronging key-frames. + + :param keyframes: the keyframes to bisect for start / end + :param start: The start time. + :param end: The end time. + :returns: A dictionary mapping `start` and `end` to 3-tuples + `start` => (seek time, start of start segment GOP, end of start segment GOP & start of middle segment); + `end` => (seek time, end of middle segment & start of end segment GOP, end of end segment GOP). + """ + + start_pos = bisect_right(keyframes, start) end_pos = bisect_left(keyframes, end) return { 'start': ( - keyframes[start_pos - 1] if start_pos > 0 else keyframes[start_pos], - keyframes[start_pos], - keyframes[start_pos + 1] + keyframes[start_pos-2] if start_pos > 1 else keyframes[start_pos-1] if start_pos > 0 else keyframes[start_pos], + keyframes[start_pos-1] if start_pos > 0 else keyframes[start_pos], + keyframes[start_pos] ), 'end': ( - keyframes[end_pos - 2] if end_pos != (len(keyframes) - 1) else keyframes[end_pos - 1], - keyframes[end_pos - 1] if end_pos != (len(keyframes) - 1) else keyframes[end_pos], - keyframes[end_pos] + keyframes[end_pos-2], + keyframes[end_pos-1], + keyframes[end_pos] if end_pos != len(keyframes) else keyframes[end_pos-1], ) } @@ -619,12 +791,22 @@ def mpegtsJoin(self, inputs: list, output: str, chapters: Optional[List[str]]=No video_bsf, audio_bsf = self.getBSF(inputs[0]) # 1. transcode to mpeg transport streams for file in inputs: + if not file: + continue name, _ = os.path.splitext(file) - outfile = '{}.ts'.format(name) + outfile = '{}-transcoded.ts'.format(name) outfiles.append(outfile) if os.path.isfile(outfile): os.remove(outfile) - args = '-v error -i "{0}" -c copy -map 0 {1} -f mpegts "{2}"'.format(file, video_bsf, outfile) + args = [ + '-v', 'error', + '-i', file, + '-c', 'copy', + '-map', '0', + video_bsf, + '-f', 'mpegts', + outfile, + ] if not self.cmdExec(self.backends.ffmpeg, args): return result # 2. losslessly concatenate at the file level @@ -634,11 +816,17 @@ def mpegtsJoin(self, inputs: list, output: str, chapters: Optional[List[str]]=No ffmetadata = None if chapters is not None and len(chapters): ffmetadata = self.getChapterFile(outfiles, chapters) - metadata = '-i "{}" -map_metadata 1 '.format(ffmetadata) + metadata = ['-i ', ffmetadata, '-map_metadata', '1'] else: - metadata = '' - args = '-v error -i "concat:{0}" {1}-c copy {2} "{3}"' \ - .format("|".join(map(str, outfiles)), metadata, audio_bsf, output) + metadata = [] + args = [ + '-v', 'error', + '-i', "concat:{0}".format("|".join(map(str, outfiles))), + ] + metadata + [ + '-c', 'copy', + audio_bsf, + output, + ] result = self.cmdExec(self.backends.ffmpeg, args) # 3. cleanup mpegts files [os.remove(file) for file in outfiles] @@ -650,32 +838,32 @@ def mpegtsJoin(self, inputs: list, output: str, chapters: Optional[List[str]]=No return result def version(self) -> str: - args = '-version' + args = ['-version'] result = self.cmdExec(self.backends.ffmpeg, args, True) return re.search(r'ffmpeg\sversion\s([\S]+)\s', result).group(1) def mediainfo(self, source: str, output: str = 'HTML') -> str: - args = '--output={0} "{1}"'.format(output, source) + args = ['--output', output, source] return self.cmdExec(self.backends.mediainfo, args, True, True) - def cmdExec(self, cmd: str, args: str=None, output: bool=False, suppresslog: bool=False, workdir: str=None, + def cmdExec(self, cmd: str, args: List[str]=None, output: bool=False, suppresslog: bool=False, workdir: str=None, mergechannels: bool=True): if self.proc.state() == QProcess.NotRunning: if cmd == self.backends.mediainfo or not mergechannels: self.proc.setProcessChannelMode(QProcess.SeparateChannels) if cmd in {self.backends.ffmpeg, self.backends.ffprobe}: - args = '-hide_banner {}'.format(args) + args = ['-hide_banner'] + args if os.getenv('DEBUG', False) or getattr(self.parent, 'verboseLogs', False): - self.logger.info('{0} {1}'.format(cmd, args if args is not None else '')) + self.logger.info('{0} {1}'.format(cmd, ' '.join(args) if args is not None else '')) self.proc.setWorkingDirectory(workdir if workdir is not None else VideoService.getAppPath()) - self.proc.start(cmd, shlex.split(args)) + self.proc.start(cmd, args) self.proc.readyReadStandardOutput.connect( - partial(self.cmdOut, self.proc.readAllStandardOutput().data().decode().strip())) + partial(self.cmdOut, self.proc.readAllStandardOutput().data().decode('iso-8859-1').strip())) self.proc.waitForFinished(-1) if cmd == self.backends.mediainfo or not mergechannels: self.proc.setProcessChannelMode(QProcess.MergedChannels) if output: - cmdoutput = self.proc.readAllStandardOutput().data().decode().strip() + cmdoutput = self.proc.readAllStandardOutput().data().decode('iso-8859-1').strip() if getattr(self.parent, 'verboseLogs', False) and not suppresslog: self.logger.info('cmd output: {}'.format(cmdoutput)) return cmdoutput diff --git a/vidcutter/mediainfo.py b/vidcutter/mediainfo.py index 1f328d0a..b0e2a01e 100644 --- a/vidcutter/mediainfo.py +++ b/vidcutter/mediainfo.py @@ -94,7 +94,7 @@ def __init__(self, media, parent=None, flags=Qt.Dialog | Qt.WindowCloseButtonHin okButton.accepted.connect(self.close) button_layout = QHBoxLayout() mediainfo_version = self.parent.videoService.cmdExec(self.parent.videoService.backends.mediainfo, - '--version', True) + ['--version'], True) if len(mediainfo_version) >= 2: mediainfo_version = mediainfo_version.split('\n')[1] mediainfo_label = QLabel('
Media information by:
%s @ ' diff --git a/vidcutter/videocutter.py b/vidcutter/videocutter.py index cd691810..0651a10a 100644 --- a/vidcutter/videocutter.py +++ b/vidcutter/videocutter.py @@ -125,8 +125,8 @@ def __init__(self, parent: QMainWindow): self.videoService.addScenes.connect(self.addScenes) self.project_files = { - 'edl': re.compile(r'(\d+(?:\.?\d+)?)\t(\d+(?:\.?\d+)?)\t([01])'), - 'vcp': re.compile(r'(\d+(?:\.?\d+)?)\t(\d+(?:\.?\d+)?)\t([01])\t(".*")$') + 'edl': re.compile(r'(\d+(?:\.\d+)?)\t(\d+(?:\.\d+)?)\t([01])'), + 'vcp': re.compile(r'(\d+(?:\.\d+)?)\t(\d+(?:\.\d+)?)\t([01])(?:\t"(.*)")?$') } self._initIcons() @@ -437,7 +437,7 @@ def __init__(self, parent: QMainWindow): if sys.platform != 'darwin': controlsLayout.addSpacing(5) - layout = QVBoxLayout() + layout = QVBoxLayout() layout.setSpacing(0) layout.setContentsMargins(10, 10, 10, 0) layout.addLayout(self.videoLayout) @@ -864,8 +864,7 @@ def openProject(self, checked: bool = False, project_file: str = None) -> Option clip_start = self.delta2QTime(float(start)) clip_end = self.delta2QTime(float(stop)) clip_image = self.captureImage(self.currentMedia, clip_start) - if project_type == 'vcp' and self.createChapters and len(chapter): - chapter = chapter[1:len(chapter) - 1] + if project_type == 'vcp' and self.createChapters and chapter is not None: if not len(chapter): chapter = None else: @@ -941,7 +940,7 @@ def saveProject(self, reboot: bool = False) -> None: 'Cannot save project file at {0}:\n\n{1}'.format(project_save, file.errorString())) return qApp.setOverrideCursor(Qt.WaitCursor) - if ptype == 'VidCutter Project (*.vcp)': + if ptype == 'VidCutter Project (*.vcp)' or ptype == 'VidCutter Project': # noinspection PyUnresolvedReferences QTextStream(file) << '{}\n'.format(self.currentMedia) for clip in self.clipTimes: @@ -949,11 +948,11 @@ def saveProject(self, reboot: bool = False) -> None: milliseconds=clip[0].msec()) stop_time = timedelta(hours=clip[1].hour(), minutes=clip[1].minute(), seconds=clip[1].second(), milliseconds=clip[1].msec()) - if ptype == 'VidCutter Project (*.vcp)': + if ptype == 'VidCutter Project (*.vcp)' or ptype == 'VidCutter Project': if self.createChapters: chapter = '"{}"'.format(clip[4]) if clip[4] is not None else '""' else: - chapter = '' + chapter = '""' # noinspection PyUnresolvedReferences QTextStream(file) << '{0}\t{1}\t{2}\t{3}\n'.format(self.delta2String(start_time), self.delta2String(stop_time), 0, chapter) @@ -1360,6 +1359,13 @@ def saveMedia(self) -> None: self.videoService.smartinit(clips) self.smartcutter(file, source_file, source_ext) return + videoWithForcedKeyframes = f'{source_file}-forced{source_ext}' + self.videoService.forceKeyframes( + source='{0}{1}'.format(source_file, source_ext), + clipTimes=self.clipTimes, + fps=eval(self.videoService.streams.video.avg_frame_rate), + output=videoWithForcedKeyframes) + steps = 3 if clips > 1 else 2 self.seekSlider.showProgress(steps) self.parent.lock_gui(True) @@ -1375,7 +1381,8 @@ def saveMedia(self) -> None: filename = os.path.join(self.workFolder, os.path.basename(filename)) filename = QDir.toNativeSeparators(filename) filelist.append(filename) - if not self.videoService.cut(source='{0}{1}'.format(source_file, source_ext), + # if not self.videoService.cut(source='{0}{1}'.format(source_file, source_ext), + if not self.videoService.cut(source=videoWithForcedKeyframes, output=filename, frametime=clip[0].toString(self.timeformat), duration=duration, @@ -1391,6 +1398,13 @@ def saveMedia(self) -> None: def smartcutter(self, file: str, source_file: str, source_ext: str) -> None: self.smartcut_monitor = Munch(clips=[], results=[], externals=0) + videoWithForcedKeyframes = f'{source_file}-forced{source_ext}' + self.videoService.forceKeyframes( + source='{0}{1}'.format(source_file, source_ext), + clipTimes=self.clipTimes, + fps=eval(self.videoService.streams.video.avg_frame_rate), + output=videoWithForcedKeyframes) + keyframes = self.videoService.getKeyframes(videoWithForcedKeyframes) for index, clip in enumerate(self.clipTimes): if len(clip[3]): self.smartcut_monitor.clips.append(clip[3]) @@ -1403,11 +1417,14 @@ def smartcutter(self, file: str, source_file: str, source_ext: str) -> None: filename = os.path.join(self.workFolder, os.path.basename(filename)) filename = QDir.toNativeSeparators(filename) self.smartcut_monitor.clips.append(filename) + + # source='{0}{1}'.format(source_file, source_ext) self.videoService.smartcut(index=index, - source='{0}{1}'.format(source_file, source_ext), + source=videoWithForcedKeyframes, output=filename, start=VideoCutter.qtime2delta(clip[0]), end=VideoCutter.qtime2delta(clip[1]), + keyframes=keyframes, allstreams=True) @pyqtSlot(bool, str)