From f6fbc8d2423ac5ae683a7613b53648db3e02e323 Mon Sep 17 00:00:00 2001 From: tassaron Date: Sun, 9 Jul 2017 14:31:19 -0400 Subject: a basic Sound component for mixing sounds to be greatly expanded... --- src/components/sound.py | 74 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/components/sound.py (limited to 'src/components/sound.py') diff --git a/src/components/sound.py b/src/components/sound.py new file mode 100644 index 0000000..d3589b3 --- /dev/null +++ b/src/components/sound.py @@ -0,0 +1,74 @@ +from PyQt5 import QtGui, QtCore, QtWidgets +import os + +from component import Component +from frame import BlankFrame + + +class Component(Component): + '''Sound''' + + modified = QtCore.pyqtSignal(int, dict) + + def widget(self, parent): + self.parent = parent + self.settings = parent.settings + page = self.loadUi('sound.ui') + + page.lineEdit_sound.textChanged.connect(self.update) + page.pushButton_sound.clicked.connect(self.pickSound) + + self.page = page + return page + + def update(self): + self.sound = self.page.lineEdit_sound.text() + super().update() + + def previewRender(self, previewWorker): + width = int(previewWorker.core.settings.value('outputWidth')) + height = int(previewWorker.core.settings.value('outputHeight')) + return self.frameRender(self.compPos, 0) + + def preFrameRender(self, **kwargs): + # super().preFrameRender(**kwargs) + return ['static', 'audio'] + + def audio(self): + return self.sound + + def pickSound(self): + sndDir = self.settings.value("componentDir", os.path.expanduser("~")) + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self.page, "Choose Sound", sndDir, + "Audio Files (%s)" % " ".join(self.core.audioFormats)) + if filename: + self.settings.setValue("componentDir", os.path.dirname(filename)) + self.page.lineEdit_sound.setText(filename) + self.update() + + def frameRender(self, layerNo, frameNo): + width = int(self.core.settings.value('outputWidth')) + height = int(self.core.settings.value('outputHeight')) + return BlankFrame(width, height) + + def loadPreset(self, pr, presetName=None): + super().loadPreset(pr, presetName) + self.page.lineEdit_sound.setText(pr['sound']) + + def savePreset(self): + return { + 'preset': self.currentPreset, + 'sound': self.sound, + } + + def commandHelp(self): + print('Path to audio file:\n path=/filepath/to/sound.ogg') + + def command(self, arg): + if not arg.startswith('preset=') and '=' in arg: + key, arg = arg.split('=', 1) + if key == 'path': + self.page.lineEdit_sound.setText(arg) + return + super().command(arg) -- cgit v1.2.3 From 4c3920e6309b4e67e3d8d809dd0b5b6cd245fd0c Mon Sep 17 00:00:00 2001 From: tassaron Date: Sun, 9 Jul 2017 21:27:29 -0400 Subject: separated creation of ffmpeg command for future use to sllow editing the command before starting the export --- src/component.py | 10 +++-- src/components/color.py | 3 +- src/components/image.py | 3 +- src/components/sound.py | 4 +- src/components/text.py | 3 +- src/core.py | 93 ++++++++++++++++++++++++++++++++++++++++ src/video_thread.py | 110 +++++------------------------------------------- 7 files changed, 116 insertions(+), 110 deletions(-) (limited to 'src/components/sound.py') diff --git a/src/component.py b/src/component.py index 306072c..7c2f753 100644 --- a/src/component.py +++ b/src/component.py @@ -27,6 +27,13 @@ class Component(QtCore.QObject): # change this number to identify new versions of a component return 1 + def properties(self): + ''' + Return a list of properties to signify if your component is + non-animated ('static') or returns sound ('audio'). + ''' + return [] + def cancel(self): # please stop any lengthy process in response to this variable self.canceled = True @@ -57,9 +64,6 @@ class Component(QtCore.QObject): self.progressBarSetText = signal to set progress bar text Use the latter two signals to update the MainWindow if needed for a long initialization procedure (i.e., for a visualizer) - - Return a list of properties to signify if your component is - non-animated ('static') or returns sound ('audio'). ''' for var, value in kwargs.items(): exec('self.%s = value' % var) diff --git a/src/components/color.py b/src/components/color.py index b87f3e9..82b45b3 100644 --- a/src/components/color.py +++ b/src/components/color.py @@ -117,8 +117,7 @@ class Component(Component): height = int(previewWorker.core.settings.value('outputHeight')) return self.drawFrame(width, height) - def preFrameRender(self, **kwargs): - super().preFrameRender(**kwargs) + def properties(self): return ['static'] def frameRender(self, layerNo, frameNo): diff --git a/src/components/image.py b/src/components/image.py index 55fa6dd..94dcb83 100644 --- a/src/components/image.py +++ b/src/components/image.py @@ -46,8 +46,7 @@ class Component(Component): height = int(previewWorker.core.settings.value('outputHeight')) return self.drawFrame(width, height) - def preFrameRender(self, **kwargs): - super().preFrameRender(**kwargs) + def properties(self): return ['static'] def frameRender(self, layerNo, frameNo): diff --git a/src/components/sound.py b/src/components/sound.py index d3589b3..1f43c83 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -31,7 +31,9 @@ class Component(Component): return self.frameRender(self.compPos, 0) def preFrameRender(self, **kwargs): - # super().preFrameRender(**kwargs) + pass + + def properties(self): return ['static', 'audio'] def audio(self): diff --git a/src/components/text.py b/src/components/text.py index 2b1884f..fb6a90e 100644 --- a/src/components/text.py +++ b/src/components/text.py @@ -119,8 +119,7 @@ class Component(Component): height = int(previewWorker.core.settings.value('outputHeight')) return self.addText(width, height) - def preFrameRender(self, **kwargs): - super().preFrameRender(**kwargs) + def properties(self): return ['static'] def frameRender(self, layerNo, frameNo): diff --git a/src/core.py b/src/core.py index db430d1..3d64c3b 100644 --- a/src/core.py +++ b/src/core.py @@ -460,6 +460,99 @@ class Core: except sp.CalledProcessError: return "avconv" + def createFfmpegCommand(self, inputFile, outputFile): + ''' + Constructs the major ffmpeg command used to export the video + ''' + + # Test if user has libfdk_aac + encoders = toolkit.checkOutput( + "%s -encoders -hide_banner" % self.FFMPEG_BIN, shell=True + ) + encoders = encoders.decode("utf-8") + + acodec = self.settings.value('outputAudioCodec') + + options = self.encoder_options + containerName = self.settings.value('outputContainer') + vcodec = self.settings.value('outputVideoCodec') + vbitrate = str(self.settings.value('outputVideoBitrate'))+'k' + acodec = self.settings.value('outputAudioCodec') + abitrate = str(self.settings.value('outputAudioBitrate'))+'k' + + for cont in options['containers']: + if cont['name'] == containerName: + container = cont['container'] + break + + vencoders = options['video-codecs'][vcodec] + aencoders = options['audio-codecs'][acodec] + + for encoder in vencoders: + if encoder in encoders: + vencoder = encoder + break + + for encoder in aencoders: + if encoder in encoders: + aencoder = encoder + break + + ffmpegCommand = [ + self.FFMPEG_BIN, + '-thread_queue_size', '512', + '-y', # overwrite the output file if it already exists. + + # INPUT VIDEO + '-f', 'rawvideo', + '-vcodec', 'rawvideo', + '-s', '%sx%s' % ( + self.settings.value('outputWidth'), + self.settings.value('outputHeight'), + ), + '-pix_fmt', 'rgba', + '-r', self.settings.value('outputFrameRate'), + '-i', '-', # the video input comes from a pipe + '-an', # the video input has no sound + + # INPUT SOUND + '-i', inputFile + ] + + extraAudio = [ + comp.audio() for comp in self.selectedComponents + if 'audio' in comp.properties() + ] + if extraAudio: + for extraInputFile in extraAudio: + ffmpegCommand.extend([ + '-i', extraInputFile + ]) + ffmpegCommand.extend([ + '-filter_complex', + 'amix=inputs=%s:duration=longest:dropout_transition=3' % str( + len(extraAudio) + 1 + ) + ]) + + ffmpegCommand.extend([ + # OUTPUT + '-vcodec', vencoder, + '-acodec', aencoder, + '-b:v', vbitrate, + '-b:a', abitrate, + '-pix_fmt', self.settings.value('outputVideoFormat'), + '-preset', self.settings.value('outputPreset'), + '-f', container + ]) + + if acodec == 'aac': + ffmpegCommand.append('-strict') + ffmpegCommand.append('-2') + + ffmpegCommand.append(outputFile) + return ffmpegCommand + def readAudioFile(self, filename, parent): command = [self.FFMPEG_BIN, '-i', filename] diff --git a/src/video_thread.py b/src/video_thread.py index bd94be3..dde71da 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -33,8 +33,8 @@ class Worker(QtCore.QObject): def __init__(self, parent=None): QtCore.QObject.__init__(self) - self.core = core.Core() - self.core.settings = parent.settings + self.core = parent.core + self.settings = parent.core.settings self.modules = parent.core.modules self.parent = parent parent.videoTask.connect(self.createVideo) @@ -114,8 +114,8 @@ class Worker(QtCore.QObject): self.components = components self.outputFile = outputFile self.extraAudio = [] - self.width = int(self.core.settings.value('outputWidth')) - self.height = int(self.core.settings.value('outputHeight')) + self.width = int(self.settings.value('outputWidth')) + self.height = int(self.settings.value('outputHeight')) self.compositeQueue = Queue() self.compositeQueue.maxsize = 20 @@ -128,7 +128,7 @@ class Worker(QtCore.QObject): self.progressBarUpdate.emit(progressBarValue) # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - # READ AUDIO AND INITIALIZE COMPONENTS + # READ AUDIO, INITIALIZE COMPONENTS, OPEN A PIPE TO FFMPEG # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ self.progressBarSetText.emit("Loading audio file...") @@ -143,8 +143,7 @@ class Worker(QtCore.QObject): self.staticComponents = {} numComps = len(self.components) for compNo, comp in enumerate(self.components): - properties = None - properties = comp.preFrameRender( + comp.preFrameRender( worker=self, completeAudioArray=self.completeAudioArray, sampleSize=self.sampleSize, @@ -152,101 +151,12 @@ class Worker(QtCore.QObject): progressBarSetText=self.progressBarSetText ) - if properties: - if 'static' in properties: - self.staticComponents[compNo] = \ - comp.frameRender(compNo, 0).copy() - if 'audio' in properties: - self.extraAudio.append(comp.audio()) + if 'static' in comp.properties(): + self.staticComponents[compNo] = \ + comp.frameRender(compNo, 0).copy() - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - # DEDUCE ENCODERS - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - - # test if user has libfdk_aac - encoders = checkOutput( - "%s -encoders -hide_banner" % self.core.FFMPEG_BIN, shell=True - ) - encoders = encoders.decode("utf-8") - - acodec = self.core.settings.value('outputAudioCodec') - - options = self.core.encoder_options - containerName = self.core.settings.value('outputContainer') - vcodec = self.core.settings.value('outputVideoCodec') - vbitrate = str(self.core.settings.value('outputVideoBitrate'))+'k' - acodec = self.core.settings.value('outputAudioCodec') - abitrate = str(self.core.settings.value('outputAudioBitrate'))+'k' - - for cont in options['containers']: - if cont['name'] == containerName: - container = cont['container'] - break - - vencoders = options['video-codecs'][vcodec] - aencoders = options['audio-codecs'][acodec] - - for encoder in vencoders: - if encoder in encoders: - vencoder = encoder - break - - for encoder in aencoders: - if encoder in encoders: - aencoder = encoder - break - - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - # CREATE PIPE TO FFMPEG - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - - ffmpegCommand = [ - self.core.FFMPEG_BIN, - '-thread_queue_size', '512', - '-y', # overwrite the output file if it already exists. - - # INPUT VIDEO - '-f', 'rawvideo', - '-vcodec', 'rawvideo', - '-s', str(self.width)+'x'+str(self.height), # size of one frame - '-pix_fmt', 'rgba', - '-r', self.core.settings.value('outputFrameRate'), - '-i', '-', # the video input comes from a pipe - '-an', # the video input has no sound - - # INPUT SOUND - '-i', inputFile - ] - - if self.extraAudio: - for extraInputFile in self.extraAudio: - ffmpegCommand.extend([ - '-i', extraInputFile - ]) - ffmpegCommand.extend([ - '-filter_complex', - 'amix=inputs=%s:duration=longest:dropout_transition=3' % str( - len(self.extraAudio) + 1 - ) - ]) - - ffmpegCommand.extend([ - # OUTPUT - '-vcodec', vencoder, - '-acodec', aencoder, - '-b:v', vbitrate, - '-b:a', abitrate, - '-pix_fmt', self.core.settings.value('outputVideoFormat'), - '-preset', self.core.settings.value('outputPreset'), - '-f', container - ]) + ffmpegCommand = self.core.createFfmpegCommand(inputFile, outputFile) print(ffmpegCommand) - - if acodec == 'aac': - ffmpegCommand.append('-strict') - ffmpegCommand.append('-2') - - ffmpegCommand.append(outputFile) self.out_pipe = openPipe( ffmpegCommand, stdin=sp.PIPE, stdout=sys.stdout, stderr=sys.stdout ) -- cgit v1.2.3 From 2e37dafd7036973a315b525f131850a6fb6d0b35 Mon Sep 17 00:00:00 2001 From: tassaron Date: Tue, 11 Jul 2017 06:06:22 -0400 Subject: fixed various bugs --- src/component.py | 9 ++++++++- src/components/image.py | 10 +++++++++- src/components/sound.py | 8 ++++---- src/components/text.py | 10 +++++----- src/components/video.py | 21 +++++++++++++++++++++ src/components/video.ui | 17 +++++++++-------- src/core.py | 4 ++-- src/mainwindow.py | 4 ++++ src/preview_thread.py | 26 ++++++++++++-------------- src/video_thread.py | 9 +++++++++ 10 files changed, 83 insertions(+), 35 deletions(-) (limited to 'src/components/sound.py') diff --git a/src/component.py b/src/component.py index 7c2f753..eea82d7 100644 --- a/src/component.py +++ b/src/component.py @@ -30,10 +30,17 @@ class Component(QtCore.QObject): def properties(self): ''' Return a list of properties to signify if your component is - non-animated ('static') or returns sound ('audio'). + non-animated ('static'), returns sound ('audio'), or has + encountered an error in configuration ('error'). ''' return [] + def error(self): + ''' + Return a string containing an error message, or None for a default. + ''' + return + def cancel(self): # please stop any lengthy process in response to this variable self.canceled = True diff --git a/src/components/image.py b/src/components/image.py index 94dcb83..07abc3f 100644 --- a/src/components/image.py +++ b/src/components/image.py @@ -47,7 +47,15 @@ class Component(Component): return self.drawFrame(width, height) def properties(self): - return ['static'] + props = ['static'] + if not os.path.exists(self.imagePath): + props.append('error') + return props + + def error(self): + if not os.path.exists(self.imagePath): + return "The image path selected on " \ + "layer %s no longer exists!" % str(self.compPos) def frameRender(self, layerNo, frameNo): width = int(self.worker.core.settings.value('outputWidth')) diff --git a/src/components/sound.py b/src/components/sound.py index 1f43c83..9c114a8 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -28,7 +28,7 @@ class Component(Component): def previewRender(self, previewWorker): width = int(previewWorker.core.settings.value('outputWidth')) height = int(previewWorker.core.settings.value('outputHeight')) - return self.frameRender(self.compPos, 0) + return BlankFrame(width, height) def preFrameRender(self, **kwargs): pass @@ -37,7 +37,7 @@ class Component(Component): return ['static', 'audio'] def audio(self): - return self.sound + return (self.sound, {}) def pickSound(self): sndDir = self.settings.value("componentDir", os.path.expanduser("~")) @@ -50,8 +50,8 @@ class Component(Component): self.update() def frameRender(self, layerNo, frameNo): - width = int(self.core.settings.value('outputWidth')) - height = int(self.core.settings.value('outputHeight')) + width = int(self.settings.value('outputWidth')) + height = int(self.settings.value('outputHeight')) return BlankFrame(width, height) def loadPreset(self, pr, presetName=None): diff --git a/src/components/text.py b/src/components/text.py index fb6a90e..ed50064 100644 --- a/src/components/text.py +++ b/src/components/text.py @@ -75,15 +75,15 @@ class Component(Component): '''Returns true x, y after considering alignment settings''' fm = QtGui.QFontMetrics(self.titleFont) if self.alignment == 0: # Left - x = self.xPosition + x = int(self.xPosition) if self.alignment == 1: # Middle offset = fm.width(self.title)/2 - x = self.xPosition - offset + x = int(self.xPosition - offset) if self.alignment == 2: # Right offset = fm.width(self.title) - x = self.xPosition - offset + x = int(self.xPosition - offset) return x, self.yPosition def loadPreset(self, pr, presetName=None): @@ -128,12 +128,12 @@ class Component(Component): return self.addText(width, height) def addText(self, width, height): - x, y = self.getXY() - image = FramePainter(width, height) + image = FramePainter(width, height) self.titleFont.setPixelSize(self.fontSize) image.setFont(self.titleFont) image.setPen(self.textColor) + x, y = self.getXY() image.drawText(x, y, self.title) return image.finalize() diff --git a/src/components/video.py b/src/components/video.py index e6890e0..5303e3a 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -123,6 +123,7 @@ class Component(Component): page.pushButton_video.clicked.connect(self.pickVideo) page.checkBox_loop.stateChanged.connect(self.update) page.checkBox_distort.stateChanged.connect(self.update) + page.checkBox_useAudio.stateChanged.connect(self.update) page.spinBox_scale.valueChanged.connect(self.update) page.spinBox_x.valueChanged.connect(self.update) page.spinBox_y.valueChanged.connect(self.update) @@ -133,6 +134,7 @@ class Component(Component): def update(self): self.videoPath = self.page.lineEdit_video.text() self.loopVideo = self.page.checkBox_loop.isChecked() + self.useAudio = self.page.checkBox_useAudio.isChecked() self.distort = self.page.checkBox_distort.isChecked() self.scale = self.page.spinBox_scale.value() self.xPosition = self.page.spinBox_x.value() @@ -151,6 +153,23 @@ class Component(Component): else: return frame + def properties(self): + props = [] + if self.useAudio: + # props.append('audio') + pass + if not os.path.exists(self.videoPath): + props.append('error') + return props + + def error(self): + if not os.path.exists(self.videoPath): + return "The video path selected on " \ + "layer %s no longer exists!" % str(self.compPos) + + def audio(self): + return (self.videoPath, {}) + def preFrameRender(self, **kwargs): super().preFrameRender(**kwargs) width = int(self.worker.core.settings.value('outputWidth')) @@ -175,6 +194,7 @@ class Component(Component): super().loadPreset(pr, presetName) self.page.lineEdit_video.setText(pr['video']) self.page.checkBox_loop.setChecked(pr['loop']) + self.page.checkBox_useAudio.setChecked(pr['useAudio']) self.page.checkBox_distort.setChecked(pr['distort']) self.page.spinBox_scale.setValue(pr['scale']) self.page.spinBox_x.setValue(pr['x']) @@ -185,6 +205,7 @@ class Component(Component): 'preset': self.currentPreset, 'video': self.videoPath, 'loop': self.loopVideo, + 'useAudio': self.useAudio, 'distort': self.distort, 'scale': self.scale, 'x': self.xPosition, diff --git a/src/components/video.ui b/src/components/video.ui index f05e8a5..97b7d6f 100644 --- a/src/components/video.ui +++ b/src/components/video.ui @@ -190,16 +190,20 @@ - + + + Use Audio + + + + + Qt::Horizontal - - QSizePolicy::Fixed - - 5 + 40 20 @@ -256,9 +260,6 @@ - - - diff --git a/src/core.py b/src/core.py index 3d64c3b..450e43b 100644 --- a/src/core.py +++ b/src/core.py @@ -524,7 +524,7 @@ class Core: if 'audio' in comp.properties() ] if extraAudio: - for extraInputFile in extraAudio: + for extraInputFile, params in extraAudio: ffmpegCommand.extend([ '-i', extraInputFile ]) @@ -532,7 +532,7 @@ class Core: '-filter_complex', 'amix=inputs=%s:duration=longest:dropout_transition=3' % str( len(extraAudio) + 1 - ) + ), ]) ffmpegCommand.extend([ diff --git a/src/mainwindow.py b/src/mainwindow.py index 3cd45d6..d21ba0a 100644 --- a/src/mainwindow.py +++ b/src/mainwindow.py @@ -713,6 +713,10 @@ class MainWindow(QtWidgets.QMainWindow): def saveCurrentProject(self): if self.currentProject: self.core.createProjectFile(self.currentProject, self.window) + try: + os.remove(self.autosavePath) + except FileNotFoundError: + pass self.updateWindowTitle() else: self.openSaveProjectDialog() diff --git a/src/preview_thread.py b/src/preview_thread.py index a72845b..fb3b792 100644 --- a/src/preview_thread.py +++ b/src/preview_thread.py @@ -25,8 +25,8 @@ class Worker(QtCore.QObject): self.parent = parent self.core = self.parent.core self.queue = queue - self.core.settings = parent.settings - self.stackedWidget = parent.window.stackedWidget + self.width = int(self.core.settings.value('outputWidth')) + self.height = int(self.core.settings.value('outputHeight')) # create checkerboard background to represent transparency self.background = FloodFrame(1920, 1080, (0, 0, 0, 0)) @@ -50,10 +50,10 @@ class Worker(QtCore.QObject): except Empty: continue - width = int(self.core.settings.value('outputWidth')) - height = int(self.core.settings.value('outputHeight')) + if self.background.width != self.width: + self.background = self.background.resize( + (self.width, self.height)) frame = self.background.copy() - frame = frame.resize((width, height)) components = nextPreviewInformation["components"] for component in reversed(components): @@ -63,23 +63,21 @@ class Worker(QtCore.QObject): ) except ValueError as e: + errMsg = "Bad frame returned by %s's preview renderer. " \ + "%s. This is a fatal error." % ( + str(component), str(e).capitalize() + ) + print(errMsg) self.parent.showMessage( - msg="Bad frame returned by %s's previewRender method. " - "This is a fatal error." % - str(component), + msg=errMsg, detail=str(e), icon='Warning', parent=None # MainWindow is in a different thread ) - self.imageCreated.emit( - QtGui.QImage(ImageQt( - FloodFrame(width, height, (0, 0, 0, 0)) - )) - ) self.error.emit() break else: - self.imageCreated.emit(ImageQt(frame)) + self.imageCreated.emit(QtGui.QImage(ImageQt(frame))) except Empty: True diff --git a/src/video_thread.py b/src/video_thread.py index dde71da..b00d512 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -151,6 +151,15 @@ class Worker(QtCore.QObject): progressBarSetText=self.progressBarSetText ) + if 'error' in comp.properties(): + self.canceled = True + errMsg = "Component #%s encountered an error!" % compNo \ + if comp.error() is None else comp.error() + self.parent.showMessage( + msg=errMsg, + icon='Warning', + parent=None # MainWindow is in a different thread + ) if 'static' in comp.properties(): self.staticComponents[compNo] = \ comp.frameRender(compNo, 0).copy() -- cgit v1.2.3 From 06c27a48bc3f52e15c15445d822e8a6f523ab98f Mon Sep 17 00:00:00 2001 From: tassaron Date: Thu, 13 Jul 2017 17:03:25 -0400 Subject: more error messages for blank components --- src/components/image.py | 7 ++++--- src/components/sound.py | 11 ++++++++++- src/components/text.py | 8 +++++++- src/components/video.py | 13 ++++++++++--- src/video_thread.py | 28 ++++++++++++++++++++++------ 5 files changed, 53 insertions(+), 14 deletions(-) (limited to 'src/components/sound.py') diff --git a/src/components/image.py b/src/components/image.py index 6465bc9..6a70424 100644 --- a/src/components/image.py +++ b/src/components/image.py @@ -48,14 +48,15 @@ class Component(Component): def properties(self): props = ['static'] - if self.imagePath and not os.path.exists(self.imagePath): + if not os.path.exists(self.imagePath): props.append('error') return props def error(self): + if not self.imagePath: + return "There is no image selected." if not os.path.exists(self.imagePath): - return "The image selected on " \ - "layer %s does not exist!" % str(self.compPos) + return "The image selected does not exist!" def frameRender(self, layerNo, frameNo): width = int(self.settings.value('outputWidth')) diff --git a/src/components/sound.py b/src/components/sound.py index 9c114a8..2ffb682 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -34,7 +34,16 @@ class Component(Component): pass def properties(self): - return ['static', 'audio'] + props = ['static', 'audio'] + if not os.path.exists(self.sound): + props.append('error') + return props + + def error(self): + if not self.sound: + return "No audio file selected." + if not os.path.exists(self.sound): + return "The audio file selected no longer exists!" def audio(self): return (self.sound, {}) diff --git a/src/components/text.py b/src/components/text.py index 4435b80..c52bdc5 100644 --- a/src/components/text.py +++ b/src/components/text.py @@ -121,7 +121,13 @@ class Component(Component): return self.addText(width, height) def properties(self): - return ['static'] + props = ['static'] + if not self.title: + props.append('error') + return props + + def error(self): + return "No text provided." def frameRender(self, layerNo, frameNo): width = int(self.settings.value('outputWidth')) diff --git a/src/components/video.py b/src/components/video.py index 53487b1..8861d70 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -115,6 +115,7 @@ class Component(Component): self.settings = parent.settings page = self.loadUi('video.ui') self.videoPath = '' + self.badVideo = False self.x = 0 self.y = 0 self.loopVideo = False @@ -156,14 +157,18 @@ class Component(Component): props = [] if self.useAudio: props.append('audio') - if self.videoPath and not os.path.exists(self.videoPath): + if not self.videoPath or self.badVideo \ + or not os.path.exists(self.videoPath): props.append('error') return props def error(self): + if not self.videoPath: + return "There is no video selected." if not os.path.exists(self.videoPath): - return "The video selected on " \ - "layer %s does not exist!" % str(self.compPos) + return "The video selected does not exist!" + if self.badVideo: + return "The video selected is corrupt!" def audio(self): return (self.videoPath, {'map': '-v'}) @@ -300,6 +305,7 @@ def finalizeFrame(self, imageData, width, height): '### BAD VIDEO SELECTED ###\n' 'Video will not export with these settings' ) + self.badVideo = True return BlankFrame(width, height) if self.scale != 100 \ @@ -308,4 +314,5 @@ def finalizeFrame(self, imageData, width, height): frame.paste(image, box=(self.xPosition, self.yPosition)) else: frame = image + self.badVideo = False return frame diff --git a/src/video_thread.py b/src/video_thread.py index bfb0cc4..9ce9cc8 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -141,7 +141,7 @@ class Worker(QtCore.QObject): ])) self.staticComponents = {} numComps = len(self.components) - for compNo, comp in enumerate(self.components): + for compNo, comp in enumerate(reversed(self.components)): comp.preFrameRender( worker=self, completeAudioArray=self.completeAudioArray, @@ -151,26 +151,41 @@ class Worker(QtCore.QObject): ) if 'error' in comp.properties(): + self.cancel() self.canceled = True errMsg = "Component #%s encountered an error!" % compNo \ - if comp.error() is None else comp.error() + if comp.error() is None else 'Component #%s (%s): %s' % ( + str(compNo), + str(comp), + comp.error() + ) self.parent.showMessage( msg=errMsg, icon='Warning', parent=None # MainWindow is in a different thread ) + break if 'static' in comp.properties(): self.staticComponents[compNo] = \ comp.frameRender(compNo, 0).copy() + if self.canceled: + print('Export cancelled by component #%s (%s): %s' % ( + compNo, str(comp), comp.error() + )) + self.progressBarSetText.emit('Export Canceled') + self.encoding.emit(False) + self.videoCreated.emit() + return + # Merge consecutive static component frames together - for compNo in range(len(self.components), 0, -1): + for compNo in range(len(self.components)): if compNo not in self.staticComponents \ - or compNo - 1 not in self.staticComponents: + or compNo + 1 not in self.staticComponents: continue - self.staticComponents[compNo - 1] = Image.alpha_composite( + self.staticComponents[compNo + 1] = Image.alpha_composite( self.staticComponents.pop(compNo), - self.staticComponents[compNo - 1] + self.staticComponents[compNo + 1] ) self.staticComponents[compNo] = None @@ -278,6 +293,7 @@ class Worker(QtCore.QObject): def cancel(self): self.canceled = True + self.stopped = True self.core.cancel() for comp in self.components: -- cgit v1.2.3 From cbbb7876155cdb057b0d779cb8ab7bc1f31116b0 Mon Sep 17 00:00:00 2001 From: tassaron Date: Thu, 13 Jul 2017 21:59:23 -0400 Subject: components automatically drawPreview & save currentPreset this makes a Component easier to program. also more comments --- src/component.py | 36 ++++++++++++++++++++++-------------- src/components/color.py | 1 - src/components/image.py | 2 +- src/components/original.py | 2 +- src/components/sound.py | 1 - src/components/text.py | 2 +- src/components/video.py | 2 +- src/core.py | 1 + src/presetmanager.py | 1 + 9 files changed, 28 insertions(+), 20 deletions(-) (limited to 'src/components/sound.py') diff --git a/src/component.py b/src/component.py index eea82d7..2b297d1 100644 --- a/src/component.py +++ b/src/component.py @@ -24,7 +24,9 @@ class Component(QtCore.QObject): return self.__doc__ def version(self): - # change this number to identify new versions of a component + ''' + Change this number to identify new versions of a component + ''' return 1 def properties(self): @@ -42,15 +44,22 @@ class Component(QtCore.QObject): return def cancel(self): - # please stop any lengthy process in response to this variable + ''' + Stop any lengthy process in response to this variable + ''' self.canceled = True def reset(self): self.canceled = False def update(self): - self.modified.emit(self.compPos, self.savePreset()) - # read your widget values, then call super().update() + ''' + Read your widget values from self.page, then call super().update() + ''' + self.parent.drawPreview() + saveValueStore = self.savePreset() + saveValueStore['preset'] = self.currentPreset + self.modified.emit(self.compPos, saveValueStore) def loadPreset(self, presetDict, presetName): ''' @@ -72,8 +81,8 @@ class Component(QtCore.QObject): Use the latter two signals to update the MainWindow if needed for a long initialization procedure (i.e., for a visualizer) ''' - for var, value in kwargs.items(): - exec('self.%s = value' % var) + for key, value in kwargs.items(): + setattr(self, key, value) def command(self, arg): ''' @@ -143,16 +152,11 @@ class Component(QtCore.QObject): def widget(self, parent): self.parent = parent - page = uic.loadUi(os.path.join( - os.path.dirname(os.path.realpath(__file__)), 'example.ui')) + page = self.loadUi('example.ui') # --- connect widget signals here --- self.page = page return page - def update(self): - self.parent.drawPreview() - super().update() - def previewRender(self, previewWorker): width = int(previewWorker.core.settings.value('outputWidth')) height = int(previewWorker.core.settings.value('outputHeight')) @@ -170,8 +174,12 @@ class Component(QtCore.QObject): def audio(self): \''' - Return audio to mix into master as a string (path to audio file), - or an object that returns raw audio data [future feature]. + Return audio to mix into master as a tuple with two elements: + The first element can be: + - A string (path to audio file), + - Or an object that returns audio data through a pipe + The second element must be a dictionary of ffmpeg parameters + to apply to the input stream. \''' @classmethod diff --git a/src/components/color.py b/src/components/color.py index da3bcf9..ef4dd95 100644 --- a/src/components/color.py +++ b/src/components/color.py @@ -110,7 +110,6 @@ class Component(Component): self.page.pushButton_color2.setEnabled(False) self.page.fillWidget.setCurrentIndex(self.fillType) - self.parent.drawPreview() super().update() def previewRender(self, previewWorker): diff --git a/src/components/image.py b/src/components/image.py index 6a70424..c0d1c0d 100644 --- a/src/components/image.py +++ b/src/components/image.py @@ -38,7 +38,7 @@ class Component(Component): self.yPosition = self.page.spinBox_y.value() self.stretched = self.page.checkBox_stretch.isChecked() self.mirror = self.page.checkBox_mirror.isChecked() - self.parent.drawPreview() + super().update() def previewRender(self, previewWorker): diff --git a/src/components/original.py b/src/components/original.py index 3599c30..f5776a4 100644 --- a/src/components/original.py +++ b/src/components/original.py @@ -51,7 +51,7 @@ class Component(Component): self.visColor = self.RGBFromString(self.page.lineEdit_visColor.text()) self.scale = self.page.spinBox_scale.value() self.y = self.page.spinBox_y.value() - self.parent.drawPreview() + super().update() def loadPreset(self, pr, presetName=None): diff --git a/src/components/sound.py b/src/components/sound.py index 2ffb682..fedc32b 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -69,7 +69,6 @@ class Component(Component): def savePreset(self): return { - 'preset': self.currentPreset, 'sound': self.sound, } diff --git a/src/components/text.py b/src/components/text.py index c52bdc5..19460e5 100644 --- a/src/components/text.py +++ b/src/components/text.py @@ -69,7 +69,7 @@ class Component(Component): btnStyle = "QPushButton { background-color : %s; outline: none; }" \ % QColor(*self.textColor).name() self.page.pushButton_textColor.setStyleSheet(btnStyle) - self.parent.drawPreview() + super().update() def getXY(self): diff --git a/src/components/video.py b/src/components/video.py index 8861d70..8aa1420 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -140,7 +140,7 @@ class Component(Component): self.scale = self.page.spinBox_scale.value() self.xPosition = self.page.spinBox_x.value() self.yPosition = self.page.spinBox_y.value() - self.parent.drawPreview() + super().update() def previewRender(self, previewWorker): diff --git a/src/core.py b/src/core.py index 3f0a6ad..2500fa6 100644 --- a/src/core.py +++ b/src/core.py @@ -414,6 +414,7 @@ class Core: f.write('[Components]\n') for comp in self.selectedComponents: saveValueStore = comp.savePreset() + saveValueStore['preset'] = comp.currentPreset f.write('%s\n' % str(comp)) f.write('%s\n' % str(comp.version())) f.write('%s\n' % toolkit.presetToString(saveValueStore)) diff --git a/src/presetmanager.py b/src/presetmanager.py index 40aa73f..0028203 100644 --- a/src/presetmanager.py +++ b/src/presetmanager.py @@ -160,6 +160,7 @@ class PresetManager(QtWidgets.QDialog): selectedComponents[index].currentPreset = newName saveValueStore = \ selectedComponents[index].savePreset() + saveValueStore['preset'] = newName componentName = str(selectedComponents[index]).strip() vers = selectedComponents[index].version() self.createNewPreset( -- cgit v1.2.3 From bcb8f27c2e4434d2296dcd66bf279b76ee0d0a4f Mon Sep 17 00:00:00 2001 From: tassaron Date: Sat, 15 Jul 2017 13:13:53 -0400 Subject: use -t on inputs so ffmpeg knows when to stop filters + better feedback in cmd mode --- src/command.py | 20 ++++++++++++++++++++ src/components/sound.py | 5 +++++ src/components/video.py | 38 ++++++++++++++++++++++++++------------ src/core.py | 8 ++++++-- src/main.py | 11 ++++++----- src/video_thread.py | 8 ++++---- 6 files changed, 67 insertions(+), 23 deletions(-) (limited to 'src/components/sound.py') diff --git a/src/command.py b/src/command.py index 41618f8..84d798d 100644 --- a/src/command.py +++ b/src/command.py @@ -7,6 +7,7 @@ from PyQt5 import QtCore import argparse import os import sys +import time import core from toolkit import LoadDefaultSettings @@ -118,8 +119,27 @@ class Command(QtCore.QObject): self, input, output ) self.worker.videoCreated.connect(self.videoCreated) + self.lastProgressUpdate = time.time() + self.worker.progressBarSetText.connect(self.progressBarSetText) self.createVideo.emit() + @QtCore.pyqtSlot(str) + def progressBarSetText(self, value): + if 'Export ' in value: + # Don't duplicate completion/failure messages + return + if not value.startswith('Exporting') \ + and time.time() - self.lastProgressUpdate >= 0.05: + # Show most messages very often + print(value) + elif time.time() - self.lastProgressUpdate >= 2.0: + # Give user time to read ffmpeg's output during the export + print('##### %s' % value) + else: + return + self.lastProgressUpdate = time.time() + + @QtCore.pyqtSlot() def videoCreated(self): quit(0) diff --git a/src/components/sound.py b/src/components/sound.py index fedc32b..4a5714b 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -79,6 +79,11 @@ class Component(Component): if not arg.startswith('preset=') and '=' in arg: key, arg = arg.split('=', 1) if key == 'path': + if '*%s' % os.path.splitext(arg)[1] \ + not in self.core.audioFormats: + print("Not a supported audio format") + quit(1) self.page.lineEdit_sound.setText(arg) return + super().command(arg) diff --git a/src/components/video.py b/src/components/video.py index b3b6a59..0b93293 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -116,6 +116,7 @@ class Component(Component): page = self.loadUi('video.ui') self.videoPath = '' self.badVideo = False + self.badAudio = False self.x = 0 self.y = 0 self.loopVideo = False @@ -161,22 +162,14 @@ class Component(Component): if self.useAudio: props.append('audio') - # test if an audio stream really exists - audioTestCommand = [ - self.core.FFMPEG_BIN, - '-i', self.videoPath, - '-vn', '-f', 'null', '-' - ] - try: - checkOutput(audioTestCommand, stderr=subprocess.DEVNULL) - except subprocess.CalledProcessError: - self.badAudio = True + self.testAudioStream() + if self.badAudio: return ['error'] return props def error(self): - if hasattr(self, 'badAudio'): + if self.badAudio: return "Could not identify an audio stream in this video." if not self.videoPath: return "There is no video selected." @@ -185,6 +178,20 @@ class Component(Component): if self.badVideo: return "The video selected is corrupt!" + def testAudioStream(self): + # test if an audio stream really exists + audioTestCommand = [ + self.core.FFMPEG_BIN, + '-i', self.videoPath, + '-vn', '-f', 'null', '-' + ] + try: + checkOutput(audioTestCommand, stderr=subprocess.DEVNULL) + except subprocess.CalledProcessError: + self.badAudio = True + else: + self.badAudio = False + def audio(self): return (self.videoPath, {'map': '-v'}) @@ -277,7 +284,7 @@ class Component(Component): if not arg.startswith('preset=') and '=' in arg: key, arg = arg.split('=', 1) if key == 'path' and os.path.exists(arg): - if os.path.splitext(arg)[1] in self.core.videoFormats: + if '*%s' % os.path.splitext(arg)[1] in self.core.videoFormats: self.page.lineEdit_video.setText(arg) self.page.spinBox_scale.setValue(100) self.page.checkBox_loop.setChecked(True) @@ -285,10 +292,17 @@ class Component(Component): else: print("Not a supported video format") quit(1) + elif arg == 'audio': + if not self.page.lineEdit_video.text(): + print("'audio' option must follow a video selection") + quit(1) + self.page.checkBox_useAudio.setChecked(True) + return super().command(arg) def commandHelp(self): print('Load a video:\n path=/filepath/to/video.mp4') + print('Using audio:\n path=/filepath/to/video.mp4 audio') def scale(scale, width, height, returntype=None): diff --git a/src/core.py b/src/core.py index 55bf261..4c12209 100644 --- a/src/core.py +++ b/src/core.py @@ -464,10 +464,11 @@ class Core: except sp.CalledProcessError: return "avconv" - def createFfmpegCommand(self, inputFile, outputFile): + def createFfmpegCommand(self, inputFile, outputFile, duration): ''' Constructs the major ffmpeg command used to export the video ''' + duration = str(duration) # Test if user has libfdk_aac encoders = toolkit.checkOutput( @@ -516,10 +517,12 @@ class Core: ), '-pix_fmt', 'rgba', '-r', self.settings.value('outputFrameRate'), + '-t', duration, '-i', '-', # the video input comes from a pipe '-an', # the video input has no sound # INPUT SOUND + '-t', duration, '-i', inputFile ] @@ -532,6 +535,7 @@ class Core: for streamNo, params in enumerate(extraAudio): extraInputFile, params = params ffmpegCommand.extend([ + '-t', duration, '-i', extraInputFile ]) if 'map' in params and params['map'] == '-v': @@ -632,7 +636,7 @@ class Core: completeAudioArrayCopy[:len(completeAudioArray)] = completeAudioArray completeAudioArray = completeAudioArrayCopy - return completeAudioArray + return (completeAudioArray, duration) def newVideoWorker(self, loader, audioFile, outputPath): self.videoThread = QtCore.QThread(loader) diff --git a/src/main.py b/src/main.py index b0ece29..2216d2a 100644 --- a/src/main.py +++ b/src/main.py @@ -8,13 +8,13 @@ import video_thread if __name__ == "__main__": - mode = 'gui' + mode = 'GUI' if len(sys.argv) > 2: - mode = 'cmd' + mode = 'commandline' elif len(sys.argv) == 2: if sys.argv[1].startswith('-'): - mode = 'cmd' + mode = 'commandline' else: # opening a project file with gui proj = sys.argv[1] @@ -22,16 +22,17 @@ if __name__ == "__main__": # normal gui launch proj = None + print('Starting Audio Visualizer in %s mode' % mode) app = QtWidgets.QApplication(sys.argv) app.setApplicationName("audio-visualizer") # app.setOrganizationName("audio-visualizer") - if mode == 'cmd': + if mode == 'commandline': from command import * main = Command() - elif mode == 'gui': + elif mode == 'GUI': from mainwindow import * import atexit import signal diff --git a/src/video_thread.py b/src/video_thread.py index 5295a3b..674765a 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -31,7 +31,6 @@ class Worker(QtCore.QObject): progressBarSetText = pyqtSignal(str) encoding = pyqtSignal(bool) - def __init__(self, parent, inputFile, outputFile, components): QtCore.QObject.__init__(self) self.core = parent.core @@ -135,7 +134,9 @@ class Worker(QtCore.QObject): # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ self.progressBarSetText.emit("Loading audio file...") - self.completeAudioArray = self.core.readAudioFile(self.inputFile, self) + self.completeAudioArray, duration = self.core.readAudioFile( + self.inputFile, self + ) self.progressBarUpdate.emit(0) self.progressBarSetText.emit("Starting components...") @@ -144,7 +145,6 @@ class Worker(QtCore.QObject): for num, component in enumerate(reversed(self.components)) ])) self.staticComponents = {} - numComps = len(self.components) for compNo, comp in enumerate(reversed(self.components)): comp.preFrameRender( worker=self, @@ -194,7 +194,7 @@ class Worker(QtCore.QObject): self.staticComponents[compNo] = None ffmpegCommand = self.core.createFfmpegCommand( - self.inputFile, self.outputFile + self.inputFile, self.outputFile, duration ) print('###### FFMPEG COMMAND ######\n%s' % " ".join(ffmpegCommand)) print('############################') -- cgit v1.2.3 From ec0abd190273b7b636c7085d7caed8220ab09172 Mon Sep 17 00:00:00 2001 From: tassaron Date: Sun, 16 Jul 2017 14:06:11 -0400 Subject: apply complex filters to audio streams from components tons of sound options could be given now, + installation using setup.py --- README.md | 21 +++++----- setup.py | 24 ++++++++--- src/component.py | 5 ++- src/components/sound.py | 23 ++++++++++- src/components/sound.ui | 50 +++++++++++++++++++++++ src/components/video.py | 16 +++++++- src/components/video.ui | 75 +++++++++++++++++++++++++++++++---- src/core.py | 103 ++++++++++++++++++++++++++++++++++++++++-------- src/main.py | 2 +- src/toolkit.py | 11 ++++-- 10 files changed, 283 insertions(+), 47 deletions(-) (limited to 'src/components/sound.py') diff --git a/README.md b/README.md index 658a22d..9149b4f 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,31 @@ audio-visualizer-python ======================= +**We need a good name that is not as generic as "audio-visualizer-python"!** -This is a little GUI tool which creates an audio visualization video from an input audio file. Different components can be added and layered to change the resulting video and add images, videos, gradients, text, etc. The component setup can be saved as a Project and exporting can be automated using commandline options. +This is a little GUI tool which creates an audio visualization video from an input audio file. Different components can be added and layered to change the resulting video and add images, videos, gradients, text, etc. Encoding options can be changed with a variety of different output containers. -The program works on Linux, macOS, and Windows. If you encounter problems running it or have other bug reports or features that you wish to see implemented, please fork the project and send me a pull request and/or file an issue on this project. +Projects can be created from the GUI and used in commandline mode for easy automation of video production. Create a template project named `template` with your typical visualizers and watermarks, and add text to the top layer from commandline: +`avp template -c 99 text "title=Episode 371" -i /this/weeks/audio.ogg -o out` -I also need a good name that is not as generic as "audio-visualizer-python"! +For more information use `avp --help` or for help with a particular component use `avp -c 0 componentName help`. + +The program works on Linux, macOS, and Windows. If you encounter problems running it or have other bug reports or features that you wish to see implemented, please fork the project and submit a pull request and/or file an issue on this project. Dependencies ------------ -Python 3, PyQt5, pillow-simd, numpy, and ffmpeg 3.3 +Python 3.4, FFmpeg 3.3, PyQt5, Pillow-SIMD, NumPy -**Note:** Pillow may be used as a drop-in replacement for Pillow-SIMD if problems are encountered installing. However this will result in much slower video export times. +**Note:** Pillow may be used as a drop-in replacement for Pillow-SIMD if problems are encountered installing. However this will result in much slower video export times. For help troubleshooting installation problems, the * For any problems with installing Pillow-SIMD, see the [Pillow installation guide](http://pillow.readthedocs.io/en/3.1.x/installation.html). Installation ------------ ### Manual installation on Ubuntu 16.04 * Install pip: `sudo apt-get install python3-pip` -* Install [prerequisites to compile Pillow](http://pillow.readthedocs.io/en/3.1.x/installation.html#building-on-linux):`sudo apt-get install python3-dev python3-setuptools libtiff5-dev libjpeg8-dev zlib1g-dev libfreetype6-dev liblcms2-dev libwebp-dev tcl8.6-dev tk8.6-dev python-tk` -* Prerequisites on **Fedora**:`sudo dnf install python3-devel redhat-rpm-config libtiff-devel libjpeg-devel libzip-devel freetype-devel lcms2-devel libwebp-devel tcl-devel tk-devel` -* Install dependencies from PyPI: `sudo pip3 install pyqt5 numpy pillow-simd` +* If Pillow is installed, it must be removed. Nothing should break because Pillow-SIMD is simply a drop-in replacement with better performance. +* Download audio-visualizer-python from this repository and run `sudo pip3 install .` in this directory * Install `ffmpeg` from the [website](http://ffmpeg.org/) or from a PPA (e.g. [https://launchpad.net/~jonathonf/+archive/ubuntu/ffmpeg-3](https://launchpad.net/~jonathonf/+archive/ubuntu/ffmpeg-3)). NOTE: `ffmpeg` in the standard repos is too old (v2.8). Old versions and `avconv` may be used but full functionality is only guaranteed with `ffmpeg` 3.3 or higher. -Download audio-visualizer-python from this repository and run it with `python3 main.py`. +Run the program with `avp` or `python3 -m avpython` ### Manual installation on Windows * **Warning:** [Compiling Pillow is difficult on Windows](http://pillow.readthedocs.io/en/3.1.x/installation.html#building-on-windows) and required for the best experience. diff --git a/setup.py b/setup.py index 4ef6077..71dc51f 100644 --- a/setup.py +++ b/setup.py @@ -12,11 +12,25 @@ def package_files(directory): setup( name='audio_visualizer_python', - version='2.0.0', - description='A little GUI tool to create audio visualization " \ - "videos out of audio files', + version='2.0.0rc1', + url='https://github.com/djfun/audio-visualizer-python/tree/feature-newgui', license='MIT', - url='https://github.com/djfun/audio-visualizer-python', + description='Create audio visualization videos from a GUI or commandline', + long_description="Create customized audio visualization videos and save " + "them as Projects to continue editing later. Different components can " + "be added and layered to add visualizers, images, videos, gradients, " + "text, etc. Use Projects created in the GUI with commandline mode to " + "automate your video production workflow without learning any complex " + "syntax.", + classifiers=[ + 'Development Status :: 4 - Beta', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3 :: Only', + 'Intended Audience :: End Users/Desktop', + 'Topic :: Multimedia :: Video :: Non-Linear Editor', + ], + keywords=['visualizer', 'visualization', 'commandline video', + 'video editor', 'ffmpeg', 'podcast'] packages=[ 'avpython', 'avpython.components' @@ -25,7 +39,7 @@ setup( package_data={ 'avpython': package_files('src'), }, - install_requires=['olefile', 'Pillow-SIMD', 'PyQt5', 'numpy'], + install_requires=['Pillow-SIMD', 'PyQt5', 'numpy'], entry_points={ 'gui_scripts': [ 'avp = avpython.main:main' diff --git a/src/component.py b/src/component.py index 2b297d1..adb170e 100644 --- a/src/component.py +++ b/src/component.py @@ -178,8 +178,9 @@ class Component(QtCore.QObject): The first element can be: - A string (path to audio file), - Or an object that returns audio data through a pipe - The second element must be a dictionary of ffmpeg parameters - to apply to the input stream. + The second element must be a dictionary of ffmpeg filters/options + to apply to the input stream. See the filter docs for ideas: + https://ffmpeg.org/ffmpeg-filters.html \''' @classmethod diff --git a/src/components/sound.py b/src/components/sound.py index 4a5714b..bd7d002 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -17,12 +17,18 @@ class Component(Component): page.lineEdit_sound.textChanged.connect(self.update) page.pushButton_sound.clicked.connect(self.pickSound) + page.checkBox_chorus.stateChanged.connect(self.update) + page.spinBox_delay.valueChanged.connect(self.update) + page.spinBox_volume.valueChanged.connect(self.update) self.page = page return page def update(self): self.sound = self.page.lineEdit_sound.text() + self.delay = self.page.spinBox_delay.value() + self.volume = self.page.spinBox_volume.value() + self.chorus = self.page.checkBox_chorus.isChecked() super().update() def previewRender(self, previewWorker): @@ -46,7 +52,16 @@ class Component(Component): return "The audio file selected no longer exists!" def audio(self): - return (self.sound, {}) + params = {} + if self.delay != 0.0: + params['adelay'] = '=%s' % str(int(self.delay * 1000.00)) + if self.chorus: + params['chorus'] = \ + '=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3' + if self.volume != 1.0: + params['volume'] = '=%s:replaygain_noclip=0' % str(self.volume) + + return (self.sound, params) def pickSound(self): sndDir = self.settings.value("componentDir", os.path.expanduser("~")) @@ -66,10 +81,16 @@ class Component(Component): def loadPreset(self, pr, presetName=None): super().loadPreset(pr, presetName) self.page.lineEdit_sound.setText(pr['sound']) + self.page.checkBox_chorus.setChecked(pr['chorus']) + self.page.spinBox_delay.setValue(pr['delay']) + self.page.spinBox_volume.setValue(pr['volume']) def savePreset(self): return { 'sound': self.sound, + 'chorus': self.chorus, + 'delay': self.delay, + 'volume': self.volume, } def commandHelp(self): diff --git a/src/components/sound.ui b/src/components/sound.ui index 5fc00c1..4c11332 100644 --- a/src/components/sound.ui +++ b/src/components/sound.ui @@ -87,6 +87,29 @@ + + + + Volume + + + + + + + x + + + 10.000000000000000 + + + 0.100000000000000 + + + 1.000000000000000 + + + @@ -100,6 +123,33 @@ + + + + Delay + + + + + + + s + + + 9999999.990000000223517 + + + 0.500000000000000 + + + + + + + Chorus + + + diff --git a/src/components/video.py b/src/components/video.py index 0b93293..e1f182c 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -127,6 +127,7 @@ class Component(Component): page.checkBox_distort.stateChanged.connect(self.update) page.checkBox_useAudio.stateChanged.connect(self.update) page.spinBox_scale.valueChanged.connect(self.update) + page.spinBox_volume.valueChanged.connect(self.update) page.spinBox_x.valueChanged.connect(self.update) page.spinBox_y.valueChanged.connect(self.update) @@ -139,9 +140,17 @@ class Component(Component): self.useAudio = self.page.checkBox_useAudio.isChecked() self.distort = self.page.checkBox_distort.isChecked() self.scale = self.page.spinBox_scale.value() + self.volume = self.page.spinBox_volume.value() self.xPosition = self.page.spinBox_x.value() self.yPosition = self.page.spinBox_y.value() + if self.useAudio: + self.page.label_volume.setEnabled(True) + self.page.spinBox_volume.setEnabled(True) + else: + self.page.label_volume.setEnabled(False) + self.page.spinBox_volume.setEnabled(False) + super().update() def previewRender(self, previewWorker): @@ -193,7 +202,10 @@ class Component(Component): self.badAudio = False def audio(self): - return (self.videoPath, {'map': '-v'}) + params = {} + if self.volume != 1.0: + params['volume'] = '=%s:replaygain_noclip=0' % str(self.volume) + return (self.videoPath, params) def preFrameRender(self, **kwargs): super().preFrameRender(**kwargs) @@ -222,6 +234,7 @@ class Component(Component): self.page.checkBox_useAudio.setChecked(pr['useAudio']) self.page.checkBox_distort.setChecked(pr['distort']) self.page.spinBox_scale.setValue(pr['scale']) + self.page.spinBox_volume.setValue(pr['volume']) self.page.spinBox_x.setValue(pr['x']) self.page.spinBox_y.setValue(pr['y']) @@ -233,6 +246,7 @@ class Component(Component): 'useAudio': self.useAudio, 'distort': self.distort, 'scale': self.scale, + 'volume': self.volume, 'x': self.xPosition, 'y': self.yPosition, } diff --git a/src/components/video.ui b/src/components/video.ui index 97b7d6f..08d15d3 100644 --- a/src/components/video.ui +++ b/src/components/video.ui @@ -10,6 +10,18 @@ 197 + + + 0 + 0 + + + + + 0 + 197 + + Form @@ -189,13 +201,6 @@ - - - - Use Audio - - - @@ -247,6 +252,62 @@ + + + + + + Use Audio + + + + + + + Volume + + + + + + + + 0 + 0 + + + + x + + + 0.000000000000000 + + + 10.000000000000000 + + + 0.100000000000000 + + + 1.000000000000000 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + diff --git a/src/core.py b/src/core.py index 4c12209..324b04f 100644 --- a/src/core.py +++ b/src/core.py @@ -468,7 +468,8 @@ class Core: ''' Constructs the major ffmpeg command used to export the video ''' - duration = str(duration) + safeDuration = "{0:.3f}".format(duration - 0.05) # used by filters + duration = "{0:.3f}".format(duration + 0.1) # used by input sources # Test if user has libfdk_aac encoders = toolkit.checkOutput( @@ -526,35 +527,99 @@ class Core: '-i', inputFile ] + # Add extra audio inputs and any needed avfilters + # NOTE: Global filters are currently hard-coded here for debugging use + globalFilters = 0 # increase to add global filters extraAudio = [ comp.audio() for comp in self.selectedComponents if 'audio' in comp.properties() ] - if extraAudio: - unwantedVideoStreams = [] - for streamNo, params in enumerate(extraAudio): + if extraAudio or globalFilters > 0: + # Add -i options for extra input files + extraFilters = {} + for streamNo, params in enumerate(reversed(extraAudio)): extraInputFile, params = params ffmpegCommand.extend([ - '-t', duration, + '-t', safeDuration, '-i', extraInputFile ]) - if 'map' in params and params['map'] == '-v': - # a video stream to remove - unwantedVideoStreams.append(streamNo + 1) + # Construct dataset of extra filters we'll need to add later + for ffmpegFilter in params: + if streamNo + 2 not in extraFilters: + extraFilters[streamNo + 2] = [] + extraFilters[streamNo + 2].append(( + ffmpegFilter, params[ffmpegFilter] + )) + + # Start creating avfilters! + extraFilterCommand = [] + + if globalFilters <= 0: + # Dictionary of last-used tmp labels for a given stream number + tmpInputs = {streamNo: -1 for streamNo in extraFilters} + else: + # Insert blank entries for global filters into extraFilters + # so the per-stream filters know what input to source later + for streamNo in range(len(extraAudio), 0, -1): + if streamNo + 1 not in extraFilters: + extraFilters[streamNo + 1] = [] + # Also filter the primary audio track + extraFilters[1] = [] + tmpInputs = { + streamNo: globalFilters - 1 + for streamNo in extraFilters + } + + # Add the global filters! + # NOTE: list length must = globalFilters, currently hardcoded + if tmpInputs: + extraFilterCommand.extend([ + '[%s:a] ashowinfo [%stmp0]' % ( + str(streamNo), + str(streamNo) + ) + for streamNo in tmpInputs + ]) + + # Now add the per-stream filters! + for streamNo, paramList in extraFilters.items(): + for param in paramList: + source = '[%s:a]' % str(streamNo) \ + if tmpInputs[streamNo] == -1 else \ + '[%stmp%s]' % ( + str(streamNo), str(tmpInputs[streamNo]) + ) + tmpInputs[streamNo] = tmpInputs[streamNo] + 1 + extraFilterCommand.append( + '%s %s%s [%stmp%s]' % ( + source, param[0], param[1], str(streamNo), + str(tmpInputs[streamNo]) + ) + ) - if unwantedVideoStreams: - ffmpegCommand.extend(['-map', '0']) - for streamNo in unwantedVideoStreams: - ffmpegCommand.extend([ - '-map', '-%s:v' % str(streamNo) - ]) + # Join all the filters together and combine into 1 stream + extraFilterCommand = "; ".join(extraFilterCommand) + '; ' \ + if tmpInputs else '' ffmpegCommand.extend([ '-filter_complex', - 'amix=inputs=%s:duration=first:dropout_transition=3' % str( - len(extraAudio) + 1 + extraFilterCommand + + '%s amix=inputs=%s:duration=first [a]' + % ( + "".join([ + '[%stmp%s]' % (str(i), tmpInputs[i]) + if i in extraFilters else '[%s:a]' % str(i) + for i in range(1, len(extraAudio) + 2) + ]), + str(len(extraAudio) + 1) ), ]) + # Only map audio from the filters, and video from the pipe + ffmpegCommand.extend([ + '-map', '0:v', + '-map', '[a]', + ]) + ffmpegCommand.extend([ # OUTPUT '-vcodec', vencoder, @@ -573,7 +638,7 @@ class Core: ffmpegCommand.append(outputFile) return ffmpegCommand - def readAudioFile(self, filename, parent): + def getAudioDuration(self, filename): command = [self.FFMPEG_BIN, '-i', filename] try: @@ -588,6 +653,10 @@ class Core: d = d.split(' ')[3] d = d.split(':') duration = float(d[0])*3600 + float(d[1])*60 + float(d[2]) + return duration + + def readAudioFile(self, filename, parent): + duration = self.getAudioDuration(filename) command = [ self.FFMPEG_BIN, diff --git a/src/main.py b/src/main.py index 317237c..6a9a25e 100644 --- a/src/main.py +++ b/src/main.py @@ -12,7 +12,7 @@ def main(): wd = os.path.dirname(os.path.realpath(__file__)) # make local imports work everywhere - sys.path.append(wd) + sys.path.insert(0, wd) mode = 'GUI' if len(sys.argv) > 2: diff --git a/src/toolkit.py b/src/toolkit.py index 589d8e6..5493f37 100644 --- a/src/toolkit.py +++ b/src/toolkit.py @@ -13,11 +13,14 @@ def badName(name): return any([letter in string.punctuation for letter in name]) +def alphabetizeDict(dictionary): + '''Alphabetizes a dict into OrderedDict ''' + return OrderedDict(sorted(dictionary.items(), key=lambda t: t[0])) + + def presetToString(dictionary): - '''Alphabetizes a dict into OrderedDict & returns string repr''' - return repr( - OrderedDict(sorted(dictionary.items(), key=lambda t: t[0])) - ) + '''Returns string repr of a preset''' + return repr(alphabetizeDict(dictionary)) def presetFromString(string): -- cgit v1.2.3 From b1713d38fa91e39f142b0c234b6405229aa149e1 Mon Sep 17 00:00:00 2001 From: tassaron Date: Mon, 17 Jul 2017 22:07:33 -0400 Subject: combined toolkit.py & frame.py into toolkit package --- README.md | 2 +- src/__main__.py | 4 +- src/component.py | 31 ----------- src/components/color.py | 9 +-- src/components/image.py | 2 +- src/components/original.py | 7 ++- src/components/sound.py | 2 +- src/components/text.py | 7 ++- src/components/video.py | 2 +- src/core.py | 2 +- src/frame.py | 66 ---------------------- src/preview_thread.py | 2 +- src/toolkit.py | 99 --------------------------------- src/toolkit/__init__.py | 1 + src/toolkit/common.py | 133 +++++++++++++++++++++++++++++++++++++++++++++ src/toolkit/frame.py | 66 ++++++++++++++++++++++ src/video_thread.py | 2 +- 17 files changed, 223 insertions(+), 214 deletions(-) delete mode 100644 src/frame.py delete mode 100644 src/toolkit.py create mode 100644 src/toolkit/__init__.py create mode 100644 src/toolkit/common.py create mode 100644 src/toolkit/frame.py (limited to 'src/components/sound.py') diff --git a/README.md b/README.md index 9149b4f..5f4e1e7 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Dependencies ------------ Python 3.4, FFmpeg 3.3, PyQt5, Pillow-SIMD, NumPy -**Note:** Pillow may be used as a drop-in replacement for Pillow-SIMD if problems are encountered installing. However this will result in much slower video export times. For help troubleshooting installation problems, the * For any problems with installing Pillow-SIMD, see the [Pillow installation guide](http://pillow.readthedocs.io/en/3.1.x/installation.html). +**Note:** Pillow may be used as a drop-in replacement for Pillow-SIMD if problems are encountered installing. However this will result in much slower video export times. For help installing Pillow-SIMD, see the [Pillow installation guide](http://pillow.readthedocs.io/en/3.1.x/installation.html). Installation ------------ diff --git a/src/__main__.py b/src/__main__.py index a68739e..3babeae 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -1,3 +1,5 @@ +# Allows for launching with python3 -m avpython + from avpython.main import main -main() \ No newline at end of file +main() diff --git a/src/component.py b/src/component.py index adb170e..7842bd6 100644 --- a/src/component.py +++ b/src/component.py @@ -112,37 +112,6 @@ class Component(QtCore.QObject): def commandHelp(self): '''Print help text for this Component's commandline arguments''' - def pickColor(self): - ''' - Use color picker to get color input from the user, - and return this as an RGB string and QPushButton stylesheet. - In a subclass apply stylesheet to any color selection widgets - ''' - dialog = QtWidgets.QColorDialog() - dialog.setOption(QtWidgets.QColorDialog.ShowAlphaChannel, True) - color = dialog.getColor() - if color.isValid(): - RGBstring = '%s,%s,%s' % ( - str(color.red()), str(color.green()), str(color.blue())) - btnStyle = "QPushButton{background-color: %s; outline: none;}" \ - % color.name() - return RGBstring, btnStyle - else: - return None, None - - def RGBFromString(self, string): - '''Turns an RGB string like "255, 255, 255" into a tuple''' - try: - tup = tuple([int(i) for i in string.split(',')]) - if len(tup) != 3: - raise ValueError - for i in tup: - if i > 255 or i < 0: - raise ValueError - return tup - except: - return (255, 255, 255) - def loadUi(self, filename): return uic.loadUi(os.path.join(self.core.componentsPath, filename)) diff --git a/src/components/color.py b/src/components/color.py index ef4dd95..8d2526d 100644 --- a/src/components/color.py +++ b/src/components/color.py @@ -5,7 +5,8 @@ from PIL.ImageQt import ImageQt import os from component import Component -from frame import BlankFrame, FloodFrame, FramePainter, PaintColor +from toolkit.frame import BlankFrame, FloodFrame, FramePainter, PaintColor +from toolkit import rgbFromString, pickColor class Component(Component): @@ -76,8 +77,8 @@ class Component(Component): return page def update(self): - self.color1 = self.RGBFromString(self.page.lineEdit_color1.text()) - self.color2 = self.RGBFromString(self.page.lineEdit_color2.text()) + self.color1 = rgbFromString(self.page.lineEdit_color1.text()) + self.color2 = rgbFromString(self.page.lineEdit_color2.text()) self.x = self.page.spinBox_x.value() self.y = self.page.spinBox_y.value() self.sizeWidth = self.page.spinBox_width.value() @@ -229,7 +230,7 @@ class Component(Component): } def pickColor(self, num): - RGBstring, btnStyle = super().pickColor() + RGBstring, btnStyle = pickColor() if not RGBstring: return if num == 1: diff --git a/src/components/image.py b/src/components/image.py index c0d1c0d..7f3f610 100644 --- a/src/components/image.py +++ b/src/components/image.py @@ -3,7 +3,7 @@ from PyQt5 import QtGui, QtCore, QtWidgets import os from component import Component -from frame import BlankFrame +from toolkit.frame import BlankFrame class Component(Component): diff --git a/src/components/original.py b/src/components/original.py index f5776a4..586204a 100644 --- a/src/components/original.py +++ b/src/components/original.py @@ -7,7 +7,8 @@ import time from copy import copy from component import Component -from frame import BlankFrame +from toolkit.frame import BlankFrame +from toolkit import rgbFromString, pickColor class Component(Component): @@ -48,7 +49,7 @@ class Component(Component): def update(self): self.layout = self.page.comboBox_visLayout.currentIndex() - self.visColor = self.RGBFromString(self.page.lineEdit_visColor.text()) + self.visColor = rgbFromString(self.page.lineEdit_visColor.text()) self.scale = self.page.spinBox_scale.value() self.y = self.page.spinBox_y.value() @@ -116,7 +117,7 @@ class Component(Component): self.visColor, self.layout) def pickColor(self): - RGBstring, btnStyle = super().pickColor() + RGBstring, btnStyle = pickColor() if not RGBstring: return self.page.lineEdit_visColor.setText(RGBstring) diff --git a/src/components/sound.py b/src/components/sound.py index bd7d002..5b06405 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -2,7 +2,7 @@ from PyQt5 import QtGui, QtCore, QtWidgets import os from component import Component -from frame import BlankFrame +from toolkit.frame import BlankFrame class Component(Component): diff --git a/src/components/text.py b/src/components/text.py index 19460e5..fc3ef5f 100644 --- a/src/components/text.py +++ b/src/components/text.py @@ -4,7 +4,8 @@ from PyQt5 import QtGui, QtCore, QtWidgets import os from component import Component -from frame import FramePainter +from toolkit.frame import FramePainter +from toolkit import rgbFromString, pickColor class Component(Component): @@ -64,7 +65,7 @@ class Component(Component): self.fontSize = self.page.spinBox_fontSize.value() self.xPosition = self.page.spinBox_xTextAlign.value() self.yPosition = self.page.spinBox_yTextAlign.value() - self.textColor = self.RGBFromString( + self.textColor = rgbFromString( self.page.lineEdit_textColor.text()) btnStyle = "QPushButton { background-color : %s; outline: none; }" \ % QColor(*self.textColor).name() @@ -146,7 +147,7 @@ class Component(Component): return image.finalize() def pickColor(self): - RGBstring, btnStyle = super().pickColor() + RGBstring, btnStyle = pickColor() if not RGBstring: return self.page.lineEdit_textColor.setText(RGBstring) diff --git a/src/components/video.py b/src/components/video.py index 9e3db30..a9f334e 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -7,7 +7,7 @@ import threading from queue import PriorityQueue from component import Component, BadComponentInit -from frame import BlankFrame +from toolkit.frame import BlankFrame from toolkit import openPipe, checkOutput diff --git a/src/core.py b/src/core.py index a0a028b..07c1f71 100644 --- a/src/core.py +++ b/src/core.py @@ -11,7 +11,7 @@ from importlib import import_module from PyQt5.QtCore import QStandardPaths import toolkit -from frame import Frame +from toolkit.frame import Frame import video_thread diff --git a/src/frame.py b/src/frame.py deleted file mode 100644 index cddb611..0000000 --- a/src/frame.py +++ /dev/null @@ -1,66 +0,0 @@ -''' - Common tools for drawing compatible frames in a Component's frameRender() -''' -from PyQt5 import QtGui -from PIL import Image -from PIL.ImageQt import ImageQt -import sys -import os - - -class Frame: - '''Controller class for all frames.''' - - -class FramePainter(QtGui.QPainter): - ''' - A QPainter for a blank frame, which can be converted into a - Pillow image with finalize() - ''' - def __init__(self, width, height): - image = BlankFrame(width, height) - self.image = QtGui.QImage(ImageQt(image)) - super().__init__(self.image) - - def setPen(self, RgbTuple): - super().setPen(PaintColor(*RgbTuple)) - - def finalize(self): - self.end() - imBytes = self.image.bits().asstring(self.image.byteCount()) - - return Image.frombytes( - 'RGBA', (self.image.width(), self.image.height()), imBytes - ) - - -class PaintColor(QtGui.QColor): - '''Reverse the painter colour if the hardware stores RGB values backward''' - def __init__(self, r, g, b, a=255): - if sys.byteorder == 'big': - super().__init__(r, g, b, a) - else: - super().__init__(b, g, r, a) - - -def FloodFrame(width, height, RgbaTuple): - return Image.new("RGBA", (width, height), RgbaTuple) - - -def BlankFrame(width, height): - '''The base frame used by each component to start drawing.''' - return FloodFrame(width, height, (0, 0, 0, 0)) - - -def Checkerboard(width, height): - ''' - A checkerboard to represent transparency to the user. - TODO: Would be cool to generate this image with numpy instead. - ''' - image = FloodFrame(1920, 1080, (0, 0, 0, 0)) - image.paste(Image.open( - os.path.join(Frame.core.wd, "background.png")), - (0, 0) - ) - image = image.resize((width, height)) - return image diff --git a/src/preview_thread.py b/src/preview_thread.py index 6c33aff..c28e048 100644 --- a/src/preview_thread.py +++ b/src/preview_thread.py @@ -9,7 +9,7 @@ from PIL.ImageQt import ImageQt from queue import Queue, Empty import os -from frame import Checkerboard +from toolkit.frame import Checkerboard class Worker(QtCore.QObject): diff --git a/src/toolkit.py b/src/toolkit.py deleted file mode 100644 index 5493f37..0000000 --- a/src/toolkit.py +++ /dev/null @@ -1,99 +0,0 @@ -''' - Common functions -''' -import string -import os -import sys -import subprocess -from collections import OrderedDict - - -def badName(name): - '''Returns whether a name contains non-alphanumeric chars''' - return any([letter in string.punctuation for letter in name]) - - -def alphabetizeDict(dictionary): - '''Alphabetizes a dict into OrderedDict ''' - return OrderedDict(sorted(dictionary.items(), key=lambda t: t[0])) - - -def presetToString(dictionary): - '''Returns string repr of a preset''' - return repr(alphabetizeDict(dictionary)) - - -def presetFromString(string): - '''Turns a string repr of OrderedDict into a regular dict''' - return dict(eval(string)) - - -def appendUppercase(lst): - for form, i in zip(lst, range(len(lst))): - lst.append(form.upper()) - return lst - - -def hideCmdWin(func): - ''' Stops CMD window from appearing on Windows. - Adapted from here: http://code.activestate.com/recipes/409002/ - ''' - def decorator(commandList, **kwargs): - if sys.platform == 'win32': - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - kwargs['startupinfo'] = startupinfo - return func(commandList, **kwargs) - return decorator - - -@hideCmdWin -def checkOutput(commandList, **kwargs): - return subprocess.check_output(commandList, **kwargs) - - -@hideCmdWin -def openPipe(commandList, **kwargs): - return subprocess.Popen(commandList, **kwargs) - - -def disableWhenEncoding(func): - ''' Blocks calls to a function while the video is being exported - in MainWindow. - ''' - def decorator(*args, **kwargs): - if args[0].encoding: - return - else: - return func(*args, **kwargs) - return decorator - - -def LoadDefaultSettings(self): - ''' Runs once at each program start-up. Fills in default settings - for any settings not found in settings.ini - ''' - self.resolutions = [ - '1920x1080', - '1280x720', - '854x480' - ] - - default = { - "outputWidth": 1280, - "outputHeight": 720, - "outputFrameRate": 30, - "outputAudioCodec": "AAC", - "outputAudioBitrate": "192", - "outputVideoCodec": "H264", - "outputVideoBitrate": "2500", - "outputVideoFormat": "yuv420p", - "outputPreset": "medium", - "outputFormat": "mp4", - "outputContainer": "MP4", - "projectDir": os.path.join(self.dataDir, 'projects'), - } - - for parm, value in default.items(): - if self.settings.value(parm) is None: - self.settings.setValue(parm, value) diff --git a/src/toolkit/__init__.py b/src/toolkit/__init__.py new file mode 100644 index 0000000..3fca275 --- /dev/null +++ b/src/toolkit/__init__.py @@ -0,0 +1 @@ +from toolkit.common import * diff --git a/src/toolkit/common.py b/src/toolkit/common.py new file mode 100644 index 0000000..e3a1649 --- /dev/null +++ b/src/toolkit/common.py @@ -0,0 +1,133 @@ +''' + Common functions +''' +from PyQt5 import QtWidgets +import string +import os +import sys +import subprocess +from collections import OrderedDict + + +def badName(name): + '''Returns whether a name contains non-alphanumeric chars''' + return any([letter in string.punctuation for letter in name]) + + +def alphabetizeDict(dictionary): + '''Alphabetizes a dict into OrderedDict ''' + return OrderedDict(sorted(dictionary.items(), key=lambda t: t[0])) + + +def presetToString(dictionary): + '''Returns string repr of a preset''' + return repr(alphabetizeDict(dictionary)) + + +def presetFromString(string): + '''Turns a string repr of OrderedDict into a regular dict''' + return dict(eval(string)) + + +def appendUppercase(lst): + for form, i in zip(lst, range(len(lst))): + lst.append(form.upper()) + return lst + + +def hideCmdWin(func): + ''' Stops CMD window from appearing on Windows. + Adapted from here: http://code.activestate.com/recipes/409002/ + ''' + def decorator(commandList, **kwargs): + if sys.platform == 'win32': + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + kwargs['startupinfo'] = startupinfo + return func(commandList, **kwargs) + return decorator + + +@hideCmdWin +def checkOutput(commandList, **kwargs): + return subprocess.check_output(commandList, **kwargs) + + +@hideCmdWin +def openPipe(commandList, **kwargs): + return subprocess.Popen(commandList, **kwargs) + + +def disableWhenEncoding(func): + ''' Blocks calls to a function while the video is being exported + in MainWindow. + ''' + def decorator(*args, **kwargs): + if args[0].encoding: + return + else: + return func(*args, **kwargs) + return decorator + + +def pickColor(): + ''' + Use color picker to get color input from the user, + and return this as an RGB string and QPushButton stylesheet. + In a subclass apply stylesheet to any color selection widgets + ''' + dialog = QtWidgets.QColorDialog() + dialog.setOption(QtWidgets.QColorDialog.ShowAlphaChannel, True) + color = dialog.getColor() + if color.isValid(): + RGBstring = '%s,%s,%s' % ( + str(color.red()), str(color.green()), str(color.blue())) + btnStyle = "QPushButton{background-color: %s; outline: none;}" \ + % color.name() + return RGBstring, btnStyle + else: + return None, None + + +def rgbFromString(string): + '''Turns an RGB string like "255, 255, 255" into a tuple''' + try: + tup = tuple([int(i) for i in string.split(',')]) + if len(tup) != 3: + raise ValueError + for i in tup: + if i > 255 or i < 0: + raise ValueError + return tup + except: + return (255, 255, 255) + + +def LoadDefaultSettings(self): + ''' Runs once at each program start-up. Fills in default settings + for any settings not found in settings.ini + ''' + self.resolutions = [ + '1920x1080', + '1280x720', + '854x480' + ] + + default = { + "outputWidth": 1280, + "outputHeight": 720, + "outputFrameRate": 30, + "outputAudioCodec": "AAC", + "outputAudioBitrate": "192", + "outputVideoCodec": "H264", + "outputVideoBitrate": "2500", + "outputVideoFormat": "yuv420p", + "outputPreset": "medium", + "outputFormat": "mp4", + "outputContainer": "MP4", + "projectDir": os.path.join(self.dataDir, 'projects'), + } + + for parm, value in default.items(): + if self.settings.value(parm) is None: + self.settings.setValue(parm, value) diff --git a/src/toolkit/frame.py b/src/toolkit/frame.py new file mode 100644 index 0000000..cddb611 --- /dev/null +++ b/src/toolkit/frame.py @@ -0,0 +1,66 @@ +''' + Common tools for drawing compatible frames in a Component's frameRender() +''' +from PyQt5 import QtGui +from PIL import Image +from PIL.ImageQt import ImageQt +import sys +import os + + +class Frame: + '''Controller class for all frames.''' + + +class FramePainter(QtGui.QPainter): + ''' + A QPainter for a blank frame, which can be converted into a + Pillow image with finalize() + ''' + def __init__(self, width, height): + image = BlankFrame(width, height) + self.image = QtGui.QImage(ImageQt(image)) + super().__init__(self.image) + + def setPen(self, RgbTuple): + super().setPen(PaintColor(*RgbTuple)) + + def finalize(self): + self.end() + imBytes = self.image.bits().asstring(self.image.byteCount()) + + return Image.frombytes( + 'RGBA', (self.image.width(), self.image.height()), imBytes + ) + + +class PaintColor(QtGui.QColor): + '''Reverse the painter colour if the hardware stores RGB values backward''' + def __init__(self, r, g, b, a=255): + if sys.byteorder == 'big': + super().__init__(r, g, b, a) + else: + super().__init__(b, g, r, a) + + +def FloodFrame(width, height, RgbaTuple): + return Image.new("RGBA", (width, height), RgbaTuple) + + +def BlankFrame(width, height): + '''The base frame used by each component to start drawing.''' + return FloodFrame(width, height, (0, 0, 0, 0)) + + +def Checkerboard(width, height): + ''' + A checkerboard to represent transparency to the user. + TODO: Would be cool to generate this image with numpy instead. + ''' + image = FloodFrame(1920, 1080, (0, 0, 0, 0)) + image.paste(Image.open( + os.path.join(Frame.core.wd, "background.png")), + (0, 0) + ) + image = image.resize((width, height)) + return image diff --git a/src/video_thread.py b/src/video_thread.py index 60db99f..1f2eaf5 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -19,7 +19,7 @@ import time import signal from toolkit import openPipe -from frame import Checkerboard +from toolkit.frame import Checkerboard class Worker(QtCore.QObject): -- cgit v1.2.3 From f454814867443ceeeca2a3a2c2a676947184503c Mon Sep 17 00:00:00 2001 From: tassaron Date: Thu, 20 Jul 2017 20:31:38 -0400 Subject: ffmpeg functions moved to toolkit, component format simplified component methods are auto-decorated & settings are now class variables --- freeze.py | 7 +- setup.py | 15 +- src/command.py | 10 +- src/component.py | 167 +++++++++++++------- src/components/color.py | 8 +- src/components/image.py | 11 +- src/components/original.py | 11 +- src/components/sound.py | 14 +- src/components/text.py | 8 +- src/components/video.py | 23 ++- src/core.py | 379 ++++++++------------------------------------- src/mainwindow.py | 81 ++++++---- src/presetmanager.py | 20 +-- src/preview_thread.py | 4 +- src/toolkit/common.py | 12 +- src/toolkit/core.py | 18 +++ src/toolkit/ffmpeg.py | 284 +++++++++++++++++++++++++++++++++ src/toolkit/frame.py | 6 +- src/video_thread.py | 45 ++++-- 19 files changed, 628 insertions(+), 495 deletions(-) create mode 100644 src/toolkit/core.py create mode 100644 src/toolkit/ffmpeg.py (limited to 'src/components/sound.py') diff --git a/freeze.py b/freeze.py index c9b7918..3281cad 100644 --- a/freeze.py +++ b/freeze.py @@ -2,8 +2,8 @@ from cx_Freeze import setup, Executable import sys import os -# Dependencies are automatically detected, but it might need -# fine tuning. +from setup import VERSION + deps = [os.path.join('src', p) for p in os.listdir('src') if p] deps.append('ffmpeg.exe' if sys.platform == 'win32' else 'ffmpeg') @@ -39,7 +39,6 @@ buildOptions = dict( include_files=deps, ) - base = 'Win32GUI' if sys.platform == 'win32' else None executables = [ @@ -53,7 +52,7 @@ executables = [ setup( name='audio-visualizer-python', - version='2.0', + version=VERSION, description='GUI tool to render visualization videos of audio files', options=dict(build_exe=buildOptions), executables=executables diff --git a/setup.py b/setup.py index 6ef688a..5abb976 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,9 @@ from setuptools import setup import os +VERSION = '2.0.0.rc1' + + def package_files(directory): paths = [] for (path, directories, filenames) in os.walk(directory): @@ -12,7 +15,7 @@ def package_files(directory): setup( name='audio_visualizer_python', - version='2.0.0rc1', + version=VERSION, url='https://github.com/djfun/audio-visualizer-python/tree/feature-newgui', license='MIT', description='Create audio visualization videos from a GUI or commandline', @@ -20,8 +23,7 @@ setup( "them as Projects to continue editing later. Different components can " "be added and layered to add visualizers, images, videos, gradients, " "text, etc. Use Projects created in the GUI with commandline mode to " - "automate your video production workflow without learning any complex " - "syntax.", + "automate your video production workflow without any complex syntax.", classifiers=[ 'Development Status :: 4 - Beta', 'License :: OSI Approved :: MIT License', @@ -29,10 +31,13 @@ setup( 'Intended Audience :: End Users/Desktop', 'Topic :: Multimedia :: Video :: Non-Linear Editor', ], - keywords=['visualizer', 'visualization', 'commandline video', - 'video editor', 'ffmpeg', 'podcast'], + keywords=[ + 'visualizer', 'visualization', 'commandline video', + 'video editor', 'ffmpeg', 'podcast' + ], packages=[ 'avpython', + 'avpython.toolkit', 'avpython.components' ], package_dir={'avpython': 'src'}, diff --git a/src/command.py b/src/command.py index 84d798d..046a1bf 100644 --- a/src/command.py +++ b/src/command.py @@ -9,8 +9,8 @@ import os import sys import time -import core -from toolkit import LoadDefaultSettings +from core import Core +from toolkit import loadDefaultSettings class Command(QtCore.QObject): @@ -19,7 +19,7 @@ class Command(QtCore.QObject): def __init__(self): QtCore.QObject.__init__(self) - self.core = core.Core() + self.core = Core() self.dataDir = self.core.dataDir self.canceled = False @@ -54,8 +54,8 @@ class Command(QtCore.QObject): nargs='*', action='append') self.args = self.parser.parse_args() - self.settings = self.core.settings - LoadDefaultSettings(self) + self.settings = Core.settings + loadDefaultSettings(self) if self.args.projpath: projPath = self.args.projpath diff --git a/src/component.py b/src/component.py index 7842bd6..92cc65c 100644 --- a/src/component.py +++ b/src/component.py @@ -1,33 +1,87 @@ ''' - Base classes for components to import. + Base classes for components to import. Read comments for some documentation + on making a valid component. ''' from PyQt5 import uic, QtCore, QtWidgets import os +from core import Core +from toolkit.common import getPresetDir -class Component(QtCore.QObject): + +class ComponentMetaclass(type(QtCore.QObject)): + ''' + Checks the validity of each Component class imported, and + mutates some attributes for easier use by the core program. + E.g., takes only major version from version string & decorates methods + ''' + def __new__(cls, name, parents, attrs): + # print('Creating %s component' % attrs['name']) + + # Turn certain class methods into properties and classmethods + for key in ('error', 'properties', 'audio', 'commandHelp'): + if key not in attrs: + continue + attrs[key] = property(attrs[key]) + + for key in ('names'): + if key not in attrs: + continue + attrs[key] = classmethod(key) + + # Turn version string into a number + try: + if 'version' not in attrs: + print( + 'No version attribute in %s. Defaulting to 1' % + attrs['name']) + attrs['version'] = 1 + else: + attrs['version'] = int(attrs['version'].split('.')[0]) + except ValueError: + print('%s component has an invalid version string:\n%s' % ( + attrs['name'], str(attrs['version']))) + except KeyError: + print('%s component has no version string.' % attrs['name']) + else: + return super().__new__(cls, name, parents, attrs) + quit(1) + + +class Component(QtCore.QObject, metaclass=ComponentMetaclass): ''' - A class for components to inherit. Read comments for documentation - on making a valid component. All subclasses must implement this signal: - modified = QtCore.pyqtSignal(int, bool) + The base class for components to inherit. ''' - def __init__(self, moduleIndex, compPos, core): + name = 'Component' + version = '1.0.0' + # The 1st number (before dot, aka the major version) is used to determine + # preset compatibility; the rest is ignored so it can be non-numeric. + + modified = QtCore.pyqtSignal(int, dict) + # ^ Signal used to tell core program that the component state changed, + # you shouldn't need to use this directly, it is used by self.update() + + def __init__(self, moduleIndex, compPos): super().__init__() self.currentPreset = None - self.canceled = False self.moduleIndex = moduleIndex self.compPos = compPos - self.core = core + + # Stop lengthy processes in response to this variable + self.canceled = False def __str__(self): - return self.__doc__ + return self.__class__.name - def version(self): - ''' - Change this number to identify new versions of a component - ''' - return 1 + def __repr__(self): + return '%s\n%s\n%s' % ( + self.__class__.name, str(self.__class__.version), self.savePreset() + ) + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # Properties + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ def properties(self): ''' @@ -43,19 +97,32 @@ class Component(QtCore.QObject): ''' return - def cancel(self): + def audio(self): ''' - Stop any lengthy process in response to this variable + Return audio to mix into master as a tuple with two elements: + The first element can be: + - A string (path to audio file), + - Or an object that returns audio data through a pipe + The second element must be a dictionary of ffmpeg filters/options + to apply to the input stream. See the filter docs for ideas: + https://ffmpeg.org/ffmpeg-filters.html ''' - self.canceled = True - def reset(self): - self.canceled = False - - def update(self): + def names(): ''' - Read your widget values from self.page, then call super().update() + Alternative names for renaming a component between project files. ''' + return [] + + def commandHelp(self): + '''Help text as string for this component's commandline arguments''' + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # Methods + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + + def update(self): + '''Read widget values from self.page, then call super().update()''' self.parent.drawPreview() saveValueStore = self.savePreset() saveValueStore['preset'] = self.currentPreset @@ -92,7 +159,7 @@ class Component(QtCore.QObject): ''' if arg.startswith('preset='): _, preset = arg.split('=', 1) - path = os.path.join(self.core.getPresetDir(self), preset) + path = os.path.join(getPresetDir(self), preset) if not os.path.exists(path): print('Couldn\'t locate preset "%s"' % preset) quit(1) @@ -106,14 +173,19 @@ class Component(QtCore.QObject): self.__doc__, 'Usage:\n' 'Open a preset for this component:\n' ' "preset=Preset Name"') - self.commandHelp() + print(self.commandHelp) quit(0) - def commandHelp(self): - '''Print help text for this Component's commandline arguments''' - def loadUi(self, filename): - return uic.loadUi(os.path.join(self.core.componentsPath, filename)) + '''Load a Qt Designer ui file to use for this component's widget''' + return uic.loadUi(os.path.join(Core.componentsPath, filename)) + + def cancel(self): + '''Stop any lengthy process in response to this variable.''' + self.canceled = True + + def reset(self): + self.canceled = False ''' ### Reference methods for creating a new component @@ -121,47 +193,34 @@ class Component(QtCore.QObject): def widget(self, parent): self.parent = parent - page = self.loadUi('example.ui') + self.settings = parent.settings + self.page = self.loadUi('example.ui') # --- connect widget signals here --- - self.page = page - return page + return self.page def previewRender(self, previewWorker): - width = int(previewWorker.core.settings.value('outputWidth')) + width = int(self.settings.value('outputWidth')) height = int(previewWorker.core.settings.value('outputHeight')) - from frame import BlankFrame + from toolkit.frame import BlankFrame image = BlankFrame(width, height) return image def frameRender(self, layerNo, frameNo): audioArrayIndex = frameNo * self.sampleSize - width = int(self.worker.core.settings.value('outputWidth')) - height = int(self.worker.core.settings.value('outputHeight')) - from frame import BlankFrame + width = int(self.settings.value('outputWidth')) + height = int(self.settings.value('outputHeight')) + from toolkit.frame import BlankFrame image = BlankFrame(width, height) return image - - def audio(self): - \''' - Return audio to mix into master as a tuple with two elements: - The first element can be: - - A string (path to audio file), - - Or an object that returns audio data through a pipe - The second element must be a dictionary of ffmpeg filters/options - to apply to the input stream. See the filter docs for ideas: - https://ffmpeg.org/ffmpeg-filters.html - \''' - - @classmethod - def names(cls): - \''' - Alternative names for renaming a component between project files. - \''' - return [] ''' class BadComponentInit(Exception): + ''' + General purpose exception components can raise to indicate + a Python issue with e.g., dynamic creation of instances or something. + Decorative for now, may have future use for logging. + ''' def __init__(self, arg, name): string = '''################################ Mandatory argument "%s" not specified diff --git a/src/components/color.py b/src/components/color.py index 8d2526d..03371e7 100644 --- a/src/components/color.py +++ b/src/components/color.py @@ -10,13 +10,12 @@ from toolkit import rgbFromString, pickColor class Component(Component): - '''Color''' - - modified = QtCore.pyqtSignal(int, dict) + name = 'Color' + version = '1.0.0' def widget(self, parent): self.parent = parent - self.settings = self.parent.core.settings + self.settings = parent.settings page = self.loadUi('color.ui') self.color1 = (0, 0, 0) @@ -211,7 +210,6 @@ class Component(Component): def savePreset(self): return { - 'preset': self.currentPreset, 'color1': self.color1, 'color2': self.color2, 'x': self.x, diff --git a/src/components/image.py b/src/components/image.py index 7f3f610..591e03e 100644 --- a/src/components/image.py +++ b/src/components/image.py @@ -2,18 +2,18 @@ from PIL import Image, ImageDraw, ImageEnhance from PyQt5 import QtGui, QtCore, QtWidgets import os +from core import Core from component import Component from toolkit.frame import BlankFrame class Component(Component): - '''Image''' - - modified = QtCore.pyqtSignal(int, dict) + name = 'Image' + version = '1.0.0' def widget(self, parent): self.parent = parent - self.settings = self.parent.core.settings + self.settings = parent.settings page = self.loadUi('image.ui') page.lineEdit_image.textChanged.connect(self.update) @@ -102,7 +102,6 @@ class Component(Component): def savePreset(self): return { - 'preset': self.currentPreset, 'image': self.imagePath, 'scale': self.scale, 'color': self.color, @@ -117,7 +116,7 @@ class Component(Component): imgDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( self.page, "Choose Image", imgDir, - "Image Files (%s)" % " ".join(self.core.imageFormats)) + "Image Files (%s)" % " ".join(Core.imageFormats)) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.page.lineEdit_image.setText(filename) diff --git a/src/components/original.py b/src/components/original.py index 586204a..ae40df3 100644 --- a/src/components/original.py +++ b/src/components/original.py @@ -12,17 +12,15 @@ from toolkit import rgbFromString, pickColor class Component(Component): - '''Classic Visualizer''' + name = 'Classic Visualizer' + version = '1.0.0' - modified = QtCore.pyqtSignal(int, dict) - - @classmethod - def names(cls): + def names(): return ['Original Audio Visualization'] def widget(self, parent): self.parent = parent - self.settings = self.parent.core.settings + self.settings = parent.settings self.visColor = (255, 255, 255) self.scale = 20 self.y = 0 @@ -68,7 +66,6 @@ class Component(Component): def savePreset(self): return { - 'preset': self.currentPreset, 'layout': self.layout, 'visColor': self.visColor, 'scale': self.scale, diff --git a/src/components/sound.py b/src/components/sound.py index 5b06405..677a22f 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -1,14 +1,14 @@ from PyQt5 import QtGui, QtCore, QtWidgets import os +from core import Core from component import Component from toolkit.frame import BlankFrame class Component(Component): - '''Sound''' - - modified = QtCore.pyqtSignal(int, dict) + name = 'Sound' + version = '1.0.0' def widget(self, parent): self.parent = parent @@ -32,8 +32,8 @@ class Component(Component): super().update() def previewRender(self, previewWorker): - width = int(previewWorker.core.settings.value('outputWidth')) - height = int(previewWorker.core.settings.value('outputHeight')) + width = int(self.settings.value('outputWidth')) + height = int(self.settings.value('outputHeight')) return BlankFrame(width, height) def preFrameRender(self, **kwargs): @@ -67,7 +67,7 @@ class Component(Component): sndDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( self.page, "Choose Sound", sndDir, - "Audio Files (%s)" % " ".join(self.core.audioFormats)) + "Audio Files (%s)" % " ".join(Core.audioFormats)) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.page.lineEdit_sound.setText(filename) @@ -101,7 +101,7 @@ class Component(Component): key, arg = arg.split('=', 1) if key == 'path': if '*%s' % os.path.splitext(arg)[1] \ - not in self.core.audioFormats: + not in Core.audioFormats: print("Not a supported audio format") quit(1) self.page.lineEdit_sound.setText(arg) diff --git a/src/components/text.py b/src/components/text.py index fc3ef5f..d511f22 100644 --- a/src/components/text.py +++ b/src/components/text.py @@ -9,9 +9,8 @@ from toolkit import rgbFromString, pickColor class Component(Component): - '''Title Text''' - - modified = QtCore.pyqtSignal(int, dict) + name = 'Title Text' + version = '1.0.0' def __init__(self, *args): super().__init__(*args) @@ -19,7 +18,7 @@ class Component(Component): def widget(self, parent): self.parent = parent - self.settings = self.parent.core.settings + self.settings = parent.settings height = int(self.settings.value('outputHeight')) width = int(self.settings.value('outputWidth')) @@ -106,7 +105,6 @@ class Component(Component): def savePreset(self): return { - 'preset': self.currentPreset, 'title': self.title, 'titleFont': self.titleFont.toString(), 'alignment': self.alignment, diff --git a/src/components/video.py b/src/components/video.py index a9f334e..b35c2e5 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -6,6 +6,7 @@ import subprocess import threading from queue import PriorityQueue +from core import Core from component import Component, BadComponentInit from toolkit.frame import BlankFrame from toolkit import openPipe, checkOutput @@ -106,9 +107,8 @@ class Video: class Component(Component): - '''Video''' - - modified = QtCore.pyqtSignal(int, dict) + name = 'Video' + version = '1.0.0' def widget(self, parent): self.parent = parent @@ -154,8 +154,8 @@ class Component(Component): super().update() def previewRender(self, previewWorker): - width = int(previewWorker.core.settings.value('outputWidth')) - height = int(previewWorker.core.settings.value('outputHeight')) + width = int(self.settings.value('outputWidth')) + height = int(self.settings.value('outputHeight')) self.updateChunksize(width, height) frame = self.getPreviewFrame(width, height) if not frame: @@ -190,7 +190,7 @@ class Component(Component): def testAudioStream(self): # test if an audio stream really exists audioTestCommand = [ - self.core.FFMPEG_BIN, + Core.FFMPEG_BIN, '-i', self.videoPath, '-vn', '-f', 'null', '-' ] @@ -209,12 +209,12 @@ class Component(Component): def preFrameRender(self, **kwargs): super().preFrameRender(**kwargs) - width = int(self.worker.core.settings.value('outputWidth')) - height = int(self.worker.core.settings.value('outputHeight')) + width = int(self.settings.value('outputWidth')) + height = int(self.settings.value('outputHeight')) self.blankFrame_ = BlankFrame(width, height) self.updateChunksize(width, height) self.video = Video( - ffmpeg=self.core.FFMPEG_BIN, videoPath=self.videoPath, + ffmpeg=Core.FFMPEG_BIN, videoPath=self.videoPath, width=width, height=height, chunkSize=self.chunkSize, frameRate=int(self.settings.value("outputFrameRate")), parent=self.parent, loopVideo=self.loopVideo, @@ -240,7 +240,6 @@ class Component(Component): def savePreset(self): return { - 'preset': self.currentPreset, 'video': self.videoPath, 'loop': self.loopVideo, 'useAudio': self.useAudio, @@ -255,7 +254,7 @@ class Component(Component): imgDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( self.page, "Choose Video", - imgDir, "Video Files (%s)" % " ".join(self.core.videoFormats) + imgDir, "Video Files (%s)" % " ".join(Core.videoFormats) ) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) @@ -298,7 +297,7 @@ class Component(Component): if not arg.startswith('preset=') and '=' in arg: key, arg = arg.split('=', 1) if key == 'path' and os.path.exists(arg): - if '*%s' % os.path.splitext(arg)[1] in self.core.videoFormats: + if '*%s' % os.path.splitext(arg)[1] in Core.videoFormats: self.page.lineEdit_video.setText(arg) self.page.spinBox_scale.setValue(100) self.page.checkBox_loop.setChecked(True) diff --git a/src/core.py b/src/core.py index 07c1f71..dd2ef18 100644 --- a/src/core.py +++ b/src/core.py @@ -1,46 +1,56 @@ ''' Home to the Core class which tracks program state. Used by GUI & commandline ''' +from PyQt5 import QtCore, QtGui, uic import sys import os -from PyQt5 import QtCore, QtGui, uic -import subprocess as sp -import numpy import json from importlib import import_module -from PyQt5.QtCore import QStandardPaths import toolkit -from toolkit.frame import Frame +from toolkit.ffmpeg import findFfmpeg import video_thread class Core: ''' MainWindow and Command module both use an instance of this class - to store the program state. This object tracks the components, - opens projects and presets, and stores settings/paths to data. + to store the main program state. This object tracks the components + as an instance, has methods for managing the components and for + opening/creating project files and presets. ''' - def __init__(self): - Frame.core = self - self.dataDir = QStandardPaths.writableLocation( - QStandardPaths.AppConfigLocation - ) - self.presetDir = os.path.join(self.dataDir, 'presets') + + @classmethod + def storeSettings(cls): + ''' + Stores settings/paths to directories as class variables + ''' if getattr(sys, 'frozen', False): # frozen - self.wd = os.path.dirname(sys.executable) + wd = os.path.dirname(sys.executable) else: - # unfrozen - self.wd = os.path.dirname(os.path.realpath(__file__)) - self.componentsPath = os.path.join(self.wd, 'components') - self.settings = QtCore.QSettings( - os.path.join(self.dataDir, 'settings.ini'), - QtCore.QSettings.IniFormat - ) + wd = os.path.dirname(os.path.realpath(__file__)) - self.loadEncoderOptions() - self.videoFormats = toolkit.appendUppercase([ + dataDir = QtCore.QStandardPaths.writableLocation( + QtCore.QStandardPaths.AppConfigLocation + ) + with open(os.path.join(wd, 'encoder-options.json')) as json_file: + encoderOptions = json.load(json_file) + + settings = { + 'wd': wd, + 'dataDir': dataDir, + 'settings': QtCore.QSettings( + os.path.join(dataDir, 'settings.ini'), + QtCore.QSettings.IniFormat), + 'presetDir': os.path.join(dataDir, 'presets'), + 'componentsPath': os.path.join(wd, 'components'), + 'encoderOptions': encoderOptions, + 'FFMPEG_BIN': findFfmpeg(), + 'canceled': False, + } + + settings['videoFormats'] = toolkit.appendUppercase([ '*.mp4', '*.mov', '*.mkv', @@ -48,7 +58,7 @@ class Core: '*.webm', '*.flv', ]) - self.audioFormats = toolkit.appendUppercase([ + settings['audioFormats'] = toolkit.appendUppercase([ '*.mp3', '*.wav', '*.ogg', @@ -56,7 +66,7 @@ class Core: '*.flac', '*.aac', ]) - self.imageFormats = toolkit.appendUppercase([ + settings['imageFormats'] = toolkit.appendUppercase([ '*.png', '*.jpg', '*.tif', @@ -68,15 +78,22 @@ class Core: '*.xpm', ]) - self.FFMPEG_BIN = self.findFfmpeg() + # Register all settings as class variables + for classvar, val in settings.items(): + setattr(cls, classvar, val) + # Make settings accessible to the toolkit package + toolkit.init(settings) + + def __init__(self): + Core.storeSettings() + self.findComponents() self.selectedComponents = [] - # copies of named presets to detect modification - self.savedPresets = {} + self.savedPresets = {} # copies of presets to detect modification def findComponents(self): def findComponents(): - for f in sorted(os.listdir(self.componentsPath)): + for f in sorted(os.listdir(Core.componentsPath)): name, ext = os.path.splitext(f) if name.startswith("__"): continue @@ -88,7 +105,7 @@ class Core: ] # store canonical module names and indexes self.moduleIndexes = [i for i in range(len(self.modules))] - self.compNames = [mod.Component.__doc__ for mod in self.modules] + self.compNames = [mod.Component.name for mod in self.modules] self.altCompNames = [] # store alternative names for modules for i, mod in enumerate(self.modules): @@ -108,7 +125,7 @@ class Core: return None component = self.modules[moduleIndex].Component( - moduleIndex, compPos, self + moduleIndex, compPos ) self.selectedComponents.insert( compPos, @@ -171,10 +188,6 @@ class Core: self.savedPresets[presetName] = dict(saveValueStore) return True - def getPresetDir(self, comp): - return os.path.join( - self.presetDir, str(comp), str(comp.version())) - def getPreset(self, filepath): '''Returns the preset dict stored at this filepath''' if not os.path.exists(filepath): @@ -204,7 +217,7 @@ class Core: widget.blockSignals(False) for key, value in data['Settings']: - self.settings.setValue(key, value) + Core.settings.setValue(key, value) for tup in data['Components']: name, vers, preset = tup @@ -215,7 +228,7 @@ class Core: if 'preset' in preset and preset['preset'] is not None: nam = preset['preset'] filepath2 = os.path.join( - self.presetDir, name, str(vers), nam) + Core.presetDir, name, str(vers), nam) origSaveValueStore = self.getPreset(filepath2) if origSaveValueStore: self.savedPresets[nam] = dict(origSaveValueStore) @@ -336,7 +349,7 @@ class Core: presetName = preset['preset'] \ if preset['preset'] else os.path.basename(filepath)[:-4] newPath = os.path.join( - self.presetDir, + Core.presetDir, name, vers, presetName @@ -354,7 +367,7 @@ class Core: def exportPreset(self, exportPath, compName, vers, origName): internalPath = os.path.join( - self.presetDir, compName, str(vers), origName + Core.presetDir, compName, str(vers), origName ) if not os.path.exists(internalPath): return @@ -378,7 +391,7 @@ class Core: '''Create a preset file (.avl) at filepath using args. Or if filepath is empty, create an internal preset using args''' if not filepath: - dirname = os.path.join(self.presetDir, compName, str(vers)) + dirname = os.path.join(Core.presetDir, compName, str(vers)) if not os.path.exists(dirname): os.makedirs(dirname) filepath = os.path.join(dirname, presetName) @@ -417,13 +430,13 @@ class Core: saveValueStore = comp.savePreset() saveValueStore['preset'] = comp.currentPreset f.write('%s\n' % str(comp)) - f.write('%s\n' % str(comp.version())) + f.write('%s\n' % str(comp.version)) f.write('%s\n' % toolkit.presetToString(saveValueStore)) f.write('\n[Settings]\n') - for key in self.settings.allKeys(): + for key in Core.settings.allKeys(): if key in settingsKeys: - f.write('%s=%s\n' % (key, self.settings.value(key))) + f.write('%s=%s\n' % (key, Core.settings.value(key))) if window: f.write('\n[WindowFields]\n') @@ -438,280 +451,8 @@ class Core: except: return False - def loadEncoderOptions(self): - file_path = os.path.join(self.wd, 'encoder-options.json') - with open(file_path) as json_file: - self.encoder_options = json.load(json_file) - - def findFfmpeg(self): - if getattr(sys, 'frozen', False): - # The application is frozen - if sys.platform == "win32": - return os.path.join(self.wd, 'ffmpeg.exe') - else: - return os.path.join(self.wd, 'ffmpeg') - - else: - if sys.platform == "win32": - return "ffmpeg" - else: - try: - with open(os.devnull, "w") as f: - toolkit.checkOutput( - ['ffmpeg', '-version'], stderr=f - ) - return "ffmpeg" - except sp.CalledProcessError: - return "avconv" - - def createFfmpegCommand(self, inputFile, outputFile, duration): - ''' - Constructs the major ffmpeg command used to export the video - ''' - safeDuration = "{0:.3f}".format(duration - 0.05) # used by filters - duration = "{0:.3f}".format(duration + 0.1) # used by input sources - - # Test if user has libfdk_aac - encoders = toolkit.checkOutput( - "%s -encoders -hide_banner" % self.FFMPEG_BIN, shell=True - ) - encoders = encoders.decode("utf-8") - - acodec = self.settings.value('outputAudioCodec') - - options = self.encoder_options - containerName = self.settings.value('outputContainer') - vcodec = self.settings.value('outputVideoCodec') - vbitrate = str(self.settings.value('outputVideoBitrate'))+'k' - acodec = self.settings.value('outputAudioCodec') - abitrate = str(self.settings.value('outputAudioBitrate'))+'k' - - for cont in options['containers']: - if cont['name'] == containerName: - container = cont['container'] - break - - vencoders = options['video-codecs'][vcodec] - aencoders = options['audio-codecs'][acodec] - - for encoder in vencoders: - if encoder in encoders: - vencoder = encoder - break - - for encoder in aencoders: - if encoder in encoders: - aencoder = encoder - break - - ffmpegCommand = [ - self.FFMPEG_BIN, - '-thread_queue_size', '512', - '-y', # overwrite the output file if it already exists. - - # INPUT VIDEO - '-f', 'rawvideo', - '-vcodec', 'rawvideo', - '-s', '%sx%s' % ( - self.settings.value('outputWidth'), - self.settings.value('outputHeight'), - ), - '-pix_fmt', 'rgba', - '-r', self.settings.value('outputFrameRate'), - '-t', duration, - '-i', '-', # the video input comes from a pipe - '-an', # the video input has no sound - - # INPUT SOUND - '-t', duration, - '-i', inputFile - ] - - # Add extra audio inputs and any needed avfilters - # NOTE: Global filters are currently hard-coded here for debugging use - globalFilters = 0 # increase to add global filters - extraAudio = [ - comp.audio() for comp in self.selectedComponents - if 'audio' in comp.properties() - ] - if extraAudio or globalFilters > 0: - # Add -i options for extra input files - extraFilters = {} - for streamNo, params in enumerate(reversed(extraAudio)): - extraInputFile, params = params - ffmpegCommand.extend([ - '-t', safeDuration, - # Tell ffmpeg about shorter clips (seemingly not needed) - # streamDuration = self.getAudioDuration(extraInputFile) - # if streamDuration > float(safeDuration) - # else "{0:.3f}".format(streamDuration), - '-i', extraInputFile - ]) - # Construct dataset of extra filters we'll need to add later - for ffmpegFilter in params: - if streamNo + 2 not in extraFilters: - extraFilters[streamNo + 2] = [] - extraFilters[streamNo + 2].append(( - ffmpegFilter, params[ffmpegFilter] - )) - - # Start creating avfilters! Popen-style, so don't use semicolons; - extraFilterCommand = [] - - if globalFilters <= 0: - # Dictionary of last-used tmp labels for a given stream number - tmpInputs = {streamNo: -1 for streamNo in extraFilters} - else: - # Insert blank entries for global filters into extraFilters - # so the per-stream filters know what input to source later - for streamNo in range(len(extraAudio), 0, -1): - if streamNo + 1 not in extraFilters: - extraFilters[streamNo + 1] = [] - # Also filter the primary audio track - extraFilters[1] = [] - tmpInputs = { - streamNo: globalFilters - 1 - for streamNo in extraFilters - } - - # Add the global filters! - # NOTE: list length must = globalFilters, currently hardcoded - if tmpInputs: - extraFilterCommand.extend([ - '[%s:a] ashowinfo [%stmp0]' % ( - str(streamNo), - str(streamNo) - ) - for streamNo in tmpInputs - ]) - - # Now add the per-stream filters! - for streamNo, paramList in extraFilters.items(): - for param in paramList: - source = '[%s:a]' % str(streamNo) \ - if tmpInputs[streamNo] == -1 else \ - '[%stmp%s]' % ( - str(streamNo), str(tmpInputs[streamNo]) - ) - tmpInputs[streamNo] = tmpInputs[streamNo] + 1 - extraFilterCommand.append( - '%s %s%s [%stmp%s]' % ( - source, param[0], param[1], str(streamNo), - str(tmpInputs[streamNo]) - ) - ) - - # Join all the filters together and combine into 1 stream - extraFilterCommand = "; ".join(extraFilterCommand) + '; ' \ - if tmpInputs else '' - ffmpegCommand.extend([ - '-filter_complex', - extraFilterCommand + - '%s amix=inputs=%s:duration=first [a]' - % ( - "".join([ - '[%stmp%s]' % (str(i), tmpInputs[i]) - if i in extraFilters else '[%s:a]' % str(i) - for i in range(1, len(extraAudio) + 2) - ]), - str(len(extraAudio) + 1) - ), - ]) - - # Only map audio from the filters, and video from the pipe - ffmpegCommand.extend([ - '-map', '0:v', - '-map', '[a]', - ]) - - ffmpegCommand.extend([ - # OUTPUT - '-vcodec', vencoder, - '-acodec', aencoder, - '-b:v', vbitrate, - '-b:a', abitrate, - '-pix_fmt', self.settings.value('outputVideoFormat'), - '-preset', self.settings.value('outputPreset'), - '-f', container - ]) - - if acodec == 'aac': - ffmpegCommand.append('-strict') - ffmpegCommand.append('-2') - - ffmpegCommand.append(outputFile) - return ffmpegCommand - - def getAudioDuration(self, filename): - command = [self.FFMPEG_BIN, '-i', filename] - - try: - fileInfo = toolkit.checkOutput(command, stderr=sp.STDOUT) - except sp.CalledProcessError as ex: - fileInfo = ex.output - - info = fileInfo.decode("utf-8").split('\n') - for line in info: - if 'Duration' in line: - d = line.split(',')[0] - d = d.split(' ')[3] - d = d.split(':') - duration = float(d[0])*3600 + float(d[1])*60 + float(d[2]) - return duration - - def readAudioFile(self, filename, parent): - duration = self.getAudioDuration(filename) - - command = [ - self.FFMPEG_BIN, - '-i', filename, - '-f', 's16le', - '-acodec', 'pcm_s16le', - '-ar', '44100', # ouput will have 44100 Hz - '-ac', '1', # mono (set to '2' for stereo) - '-'] - in_pipe = toolkit.openPipe( - command, stdout=sp.PIPE, stderr=sp.DEVNULL, bufsize=10**8 - ) - - completeAudioArray = numpy.empty(0, dtype="int16") - - progress = 0 - lastPercent = None - while True: - if self.canceled: - break - # read 2 seconds of audio - progress += 4 - raw_audio = in_pipe.stdout.read(88200*4) - if len(raw_audio) == 0: - break - audio_array = numpy.fromstring(raw_audio, dtype="int16") - completeAudioArray = numpy.append(completeAudioArray, audio_array) - - percent = int(100*(progress/duration)) - if percent >= 100: - percent = 100 - - if lastPercent != percent: - string = 'Loading audio file: '+str(percent)+'%' - parent.progressBarSetText.emit(string) - parent.progressBarUpdate.emit(percent) - - lastPercent = percent - - in_pipe.kill() - in_pipe.wait() - - # add 0s the end - completeAudioArrayCopy = numpy.zeros( - len(completeAudioArray) + 44100, dtype="int16") - completeAudioArrayCopy[:len(completeAudioArray)] = completeAudioArray - completeAudioArray = completeAudioArrayCopy - - return (completeAudioArray, duration) - def newVideoWorker(self, loader, audioFile, outputPath): + '''loader is MainWindow or Command object which must own the thread''' self.videoThread = QtCore.QThread(loader) videoWorker = video_thread.Worker( loader, audioFile, outputPath, self.selectedComponents @@ -727,7 +468,9 @@ class Core: self.videoThread.wait() def cancel(self): - self.canceled = True + Core.canceled = True + toolkit.cancel() def reset(self): - self.canceled = False + Core.canceled = False + toolkit.reset() diff --git a/src/mainwindow.py b/src/mainwindow.py index ca8e697..9944d1a 100644 --- a/src/mainwindow.py +++ b/src/mainwindow.py @@ -14,13 +14,17 @@ import signal import filecmp import time -import core +from core import Core import preview_thread from presetmanager import PresetManager -from toolkit import LoadDefaultSettings, disableWhenEncoding, checkOutput +from toolkit import loadDefaultSettings, disableWhenEncoding, checkOutput class PreviewWindow(QtWidgets.QLabel): + ''' + Paints the preview QLabel and maintains the aspect ratio when the + window is resized. + ''' def __init__(self, parent, img): super(PreviewWindow, self).__init__() self.parent = parent @@ -47,6 +51,14 @@ class PreviewWindow(QtWidgets.QLabel): class MainWindow(QtWidgets.QMainWindow): + ''' + The MainWindow wraps many Core methods in order to update the GUI + accordingly. E.g., instead of self.core.openProject(), it will use + self.openProject() and update the window titlebar within the wrapper. + + MainWindow manages the autosave feature, although Core has the + primary functions for opening and creating project files. + ''' createVideo = QtCore.pyqtSignal() newTask = QtCore.pyqtSignal(list) # for the preview window @@ -57,25 +69,26 @@ class MainWindow(QtWidgets.QMainWindow): # print('main thread id: {}'.format(QtCore.QThread.currentThreadId())) self.window = window - self.core = core.Core() + self.core = Core() self.pages = [] # widgets of component settings self.lastAutosave = time.time() self.encoding = False # Create data directory, load/create settings - self.dataDir = self.core.dataDir + self.dataDir = Core.dataDir + self.presetDir = Core.presetDir self.autosavePath = os.path.join(self.dataDir, 'autosave.avp') - self.settings = self.core.settings - LoadDefaultSettings(self) + self.settings = Core.settings + loadDefaultSettings(self) self.presetManager = PresetManager( uic.loadUi( - os.path.join(self.core.wd, 'presetmanager.ui')), self) + os.path.join(Core.wd, 'presetmanager.ui')), self) if not os.path.exists(self.dataDir): os.makedirs(self.dataDir) for neededDirectory in ( - self.core.presetDir, self.settings.value("projectDir")): + self.presetDir, self.settings.value("projectDir")): if not os.path.exists(neededDirectory): os.mkdir(neededDirectory) @@ -120,7 +133,7 @@ class MainWindow(QtWidgets.QMainWindow): window.pushButton_Cancel.clicked.connect(self.stopVideo) - for i, container in enumerate(self.core.encoder_options['containers']): + for i, container in enumerate(Core.encoderOptions['containers']): window.comboBox_videoContainer.addItem(container['name']) if container['name'] == self.settings.value('outputContainer'): selectedContainer = i @@ -160,14 +173,14 @@ class MainWindow(QtWidgets.QMainWindow): window.spinBox_aBitrate.valueChanged.connect(self.updateCodecSettings) self.previewWindow = PreviewWindow(self, os.path.join( - self.core.wd, "background.png")) + Core.wd, "background.png")) window.verticalLayout_previewWrapper.addWidget(self.previewWindow) # Make component buttons self.compMenu = QMenu() self.compActions = [] for i, comp in enumerate(self.core.modules): - action = self.compMenu.addAction(comp.Component.__doc__) + action = self.compMenu.addAction(comp.Component.name) action.triggered.connect( lambda _, item=i: self.core.insertComponent(0, item, self) ) @@ -336,8 +349,14 @@ class MainWindow(QtWidgets.QMainWindow): "Ctrl+Down", self.window, activated=lambda: self.moveComponent(1) ) - QtWidgets.QShortcut("Ctrl+Home", self.window, self.moveComponentTop) - QtWidgets.QShortcut("Ctrl+End", self.window, self.moveComponentBottom) + QtWidgets.QShortcut( + "Ctrl+Home", self.window, + activated=lambda: self.moveComponent('top') + ) + QtWidgets.QShortcut( + "Ctrl+End", self.window, + activated=lambda: self.moveComponent('bottom') + ) QtWidgets.QShortcut("Ctrl+r", self.window, self.removeComponent) @QtCore.pyqtSlot() @@ -389,7 +408,7 @@ class MainWindow(QtWidgets.QMainWindow): vCodecWidget.clear() aCodecWidget.clear() - for container in self.core.encoder_options['containers']: + for container in Core.encoderOptions['containers']: if container['name'] == name: for vCodec in container['video-codecs']: vCodecWidget.addItem(vCodec) @@ -397,6 +416,7 @@ class MainWindow(QtWidgets.QMainWindow): aCodecWidget.addItem(aCodec) def updateCodecSettings(self): + '''Updates settings.ini to match encoder option widgets''' vCodecWidget = self.window.comboBox_videoCodec vBitrateWidget = self.window.spinBox_vBitrate aBitrateWidget = self.window.spinBox_aBitrate @@ -416,11 +436,12 @@ class MainWindow(QtWidgets.QMainWindow): if not self.currentProject: if os.path.exists(self.autosavePath): os.remove(self.autosavePath) - elif force or time.time() - self.lastAutosave >= 0.1: + elif force or time.time() - self.lastAutosave >= 0.2: self.core.createProjectFile(self.autosavePath, self.window) self.lastAutosave = time.time() def autosaveExists(self, identical=True): + '''Determines if creating the autosave should be blocked.''' try: if self.currentProject and os.path.exists(self.autosavePath) \ and filecmp.cmp( @@ -432,6 +453,7 @@ class MainWindow(QtWidgets.QMainWindow): return False def saveProjectChanges(self): + '''Overwrites project file with autosave file''' try: os.remove(self.currentProject) os.rename(self.autosavePath, self.currentProject) @@ -447,7 +469,7 @@ class MainWindow(QtWidgets.QMainWindow): fileName, _ = QtWidgets.QFileDialog.getOpenFileName( self.window, "Open Audio File", - inputDir, "Audio Files (%s)" % " ".join(self.core.audioFormats)) + inputDir, "Audio Files (%s)" % " ".join(Core.audioFormats)) if fileName: self.settings.setValue("inputDir", os.path.dirname(fileName)) @@ -460,7 +482,7 @@ class MainWindow(QtWidgets.QMainWindow): self.window, "Set Output Video File", outputDir, "Video Files (%s);; All Files (*)" % " ".join( - self.core.videoFormats)) + Core.videoFormats)) if fileName: self.settings.setValue("outputDir", os.path.dirname(fileName)) @@ -587,10 +609,11 @@ class MainWindow(QtWidgets.QMainWindow): def showFfmpegCommand(self): from textwrap import wrap - command = self.core.createFfmpegCommand( + from toolkit.ffmpeg import createFfmpegCommand + command = createFfmpegCommand( self.window.lineEdit_audioFile.text(), self.window.lineEdit_outputFile.text(), - self.core.getAudioDuration(self.window.lineEdit_audioFile.text()) + self.core.selectedComponents ) lines = wrap(" ".join(command), 49) self.showMessage( @@ -603,7 +626,7 @@ class MainWindow(QtWidgets.QMainWindow): componentList.insertItem( index, - self.core.selectedComponents[index].__doc__) + self.core.selectedComponents[index].name) componentList.setCurrentRow(index) # connect to signal that adds an asterisk when modified @@ -632,6 +655,10 @@ class MainWindow(QtWidgets.QMainWindow): def moveComponent(self, change): '''Moves a component relatively from its current position''' componentList = self.window.listWidget_componentList + if change == 'top': + change = -componentList.currentRow() + elif change == 'bottom': + change = len(componentList)-componentList.currentRow()-1 stackedWidget = self.window.stackedWidget row = componentList.currentRow() @@ -650,21 +677,9 @@ class MainWindow(QtWidgets.QMainWindow): stackedWidget.setCurrentIndex(newRow) self.drawPreview() - @disableWhenEncoding - def moveComponentTop(self): - componentList = self.window.listWidget_componentList - row = -componentList.currentRow() - self.moveComponent(row) - - @disableWhenEncoding - def moveComponentBottom(self): - componentList = self.window.listWidget_componentList - row = len(componentList)-componentList.currentRow()-1 - self.moveComponent(row) - @disableWhenEncoding def dragComponent(self, event): - '''Drop event for the component listwidget''' + '''Used as Qt drop event for the component listwidget''' componentList = self.window.listWidget_componentList modelIndexes = [ diff --git a/src/presetmanager.py b/src/presetmanager.py index 6e003a1..825fdee 100644 --- a/src/presetmanager.py +++ b/src/presetmanager.py @@ -15,11 +15,11 @@ class PresetManager(QtWidgets.QDialog): self.parent = parent self.core = parent.core self.settings = parent.settings - self.presetDir = self.core.presetDir + self.presetDir = parent.presetDir if not self.settings.value('presetDir'): self.settings.setValue( "presetDir", - os.path.join(self.core.dataDir, 'projects')) + os.path.join(parent.dataDir, 'projects')) self.findPresets() @@ -161,7 +161,7 @@ class PresetManager(QtWidgets.QDialog): selectedComponents[index].savePreset() saveValueStore['preset'] = newName componentName = str(selectedComponents[index]).strip() - vers = selectedComponents[index].version() + vers = selectedComponents[index].version self.createNewPreset( componentName, vers, newName, saveValueStore, window=self.parent.window) @@ -195,13 +195,13 @@ class PresetManager(QtWidgets.QDialog): def openPreset(self, presetName, compPos=None): componentList = self.parent.window.listWidget_componentList - selectedComponents = self.parent.core.selectedComponents + selectedComponents = self.core.selectedComponents index = compPos if compPos is not None else componentList.currentRow() if index == -1: return componentName = str(selectedComponents[index]).strip() - version = selectedComponents[index].version() + version = selectedComponents[index].version dirname = os.path.join(self.presetDir, componentName, str(version)) filepath = os.path.join(dirname, presetName) self.core.openPreset(filepath, index, presetName) @@ -243,6 +243,7 @@ class PresetManager(QtWidgets.QDialog): parent=window if window else self.window) def openRenamePresetDialog(self): + # TODO: maintain consistency by changing this to call createNewPreset() presetList = self.window.listWidget_presets if presetList.currentRow() == -1: return @@ -273,11 +274,12 @@ class PresetManager(QtWidgets.QDialog): os.rename(oldPath, newPath) self.findPresets() self.drawPresetList() - for i, comp in enumerate(self.core.selectedComponents): - if comp.currentPreset == oldName: - comp.currentPreset = newName - self.parent.updateComponentTitle(i, True) + if toolkit.getPresetDir(comp) == path \ + and comp.currentPreset == oldName: + self.core.openPreset(newPath, i, newName) + self.parent.updateComponentTitle(i, False) + self.parent.drawPreview() break def openImportDialog(self): diff --git a/src/preview_thread.py b/src/preview_thread.py index c28e048..3fc73b3 100644 --- a/src/preview_thread.py +++ b/src/preview_thread.py @@ -22,8 +22,8 @@ class Worker(QtCore.QObject): parent.newTask.connect(self.createPreviewImage) parent.processTask.connect(self.process) self.parent = parent - self.core = self.parent.core - self.settings = self.parent.core.settings + self.core = parent.core + self.settings = parent.settings self.queue = queue width = int(self.settings.value('outputWidth')) diff --git a/src/toolkit/common.py b/src/toolkit/common.py index e3a1649..763d582 100644 --- a/src/toolkit/common.py +++ b/src/toolkit/common.py @@ -8,6 +8,13 @@ import sys import subprocess from collections import OrderedDict +from toolkit.core import * + + +def getPresetDir(comp): + '''Get the preset subdirectory for a particular version of a component''' + return os.path.join(Core.presetDir, str(comp), str(comp.version)) + def badName(name): '''Returns whether a name contains non-alphanumeric chars''' @@ -103,8 +110,9 @@ def rgbFromString(string): return (255, 255, 255) -def LoadDefaultSettings(self): - ''' Runs once at each program start-up. Fills in default settings +def loadDefaultSettings(self): + ''' + Runs once at each program start-up. Fills in default settings for any settings not found in settings.ini ''' self.resolutions = [ diff --git a/src/toolkit/core.py b/src/toolkit/core.py new file mode 100644 index 0000000..a96a684 --- /dev/null +++ b/src/toolkit/core.py @@ -0,0 +1,18 @@ +class Core: + '''A very complicated class for tracking settings''' + + +def init(settings): + global Core + for classvar, val in settings.items(): + setattr(Core, classvar, val) + + +def cancel(): + global Core + Core.canceled = True + + +def reset(): + global Core + Core.canceled = False diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py new file mode 100644 index 0000000..89d4e9d --- /dev/null +++ b/src/toolkit/ffmpeg.py @@ -0,0 +1,284 @@ +''' + Tools for using ffmpeg +''' +import numpy +import sys +import os +import subprocess as sp + +from toolkit.common import Core, checkOutput, openPipe + + +def findFfmpeg(): + if getattr(sys, 'frozen', False): + # The application is frozen + if sys.platform == "win32": + return os.path.join(Core.wd, 'ffmpeg.exe') + else: + return os.path.join(Core.wd, 'ffmpeg') + + else: + if sys.platform == "win32": + return "ffmpeg" + else: + try: + with open(os.devnull, "w") as f: + checkOutput( + ['ffmpeg', '-version'], stderr=f + ) + return "ffmpeg" + except sp.CalledProcessError: + return "avconv" + + +def createFfmpegCommand(inputFile, outputFile, components, duration=-1): + ''' + Constructs the major ffmpeg command used to export the video + ''' + if duration == -1: + duration = getAudioDuration(inputFile) + + safeDuration = "{0:.3f}".format(duration - 0.05) # used by filters + duration = "{0:.3f}".format(duration + 0.1) # used by input sources + + # Test if user has libfdk_aac + encoders = checkOutput( + "%s -encoders -hide_banner" % Core.FFMPEG_BIN, shell=True + ) + encoders = encoders.decode("utf-8") + + acodec = Core.settings.value('outputAudioCodec') + + options = Core.encoderOptions + containerName = Core.settings.value('outputContainer') + vcodec = Core.settings.value('outputVideoCodec') + vbitrate = str(Core.settings.value('outputVideoBitrate'))+'k' + acodec = Core.settings.value('outputAudioCodec') + abitrate = str(Core.settings.value('outputAudioBitrate'))+'k' + + for cont in options['containers']: + if cont['name'] == containerName: + container = cont['container'] + break + + vencoders = options['video-codecs'][vcodec] + aencoders = options['audio-codecs'][acodec] + + for encoder in vencoders: + if encoder in encoders: + vencoder = encoder + break + + for encoder in aencoders: + if encoder in encoders: + aencoder = encoder + break + + ffmpegCommand = [ + Core.FFMPEG_BIN, + '-thread_queue_size', '512', + '-y', # overwrite the output file if it already exists. + + # INPUT VIDEO + '-f', 'rawvideo', + '-vcodec', 'rawvideo', + '-s', '%sx%s' % ( + Core.settings.value('outputWidth'), + Core.settings.value('outputHeight'), + ), + '-pix_fmt', 'rgba', + '-r', Core.settings.value('outputFrameRate'), + '-t', duration, + '-i', '-', # the video input comes from a pipe + '-an', # the video input has no sound + + # INPUT SOUND + '-t', duration, + '-i', inputFile + ] + + # Add extra audio inputs and any needed avfilters + # NOTE: Global filters are currently hard-coded here for debugging use + globalFilters = 0 # increase to add global filters + extraAudio = [ + comp.audio for comp in components + if 'audio' in comp.properties + ] + if extraAudio or globalFilters > 0: + # Add -i options for extra input files + extraFilters = {} + for streamNo, params in enumerate(reversed(extraAudio)): + extraInputFile, params = params + ffmpegCommand.extend([ + '-t', safeDuration, + # Tell ffmpeg about shorter clips (seemingly not needed) + # streamDuration = getAudioDuration(extraInputFile) + # if streamDuration > float(safeDuration) + # else "{0:.3f}".format(streamDuration), + '-i', extraInputFile + ]) + # Construct dataset of extra filters we'll need to add later + for ffmpegFilter in params: + if streamNo + 2 not in extraFilters: + extraFilters[streamNo + 2] = [] + extraFilters[streamNo + 2].append(( + ffmpegFilter, params[ffmpegFilter] + )) + + # Start creating avfilters! Popen-style, so don't use semicolons; + extraFilterCommand = [] + + if globalFilters <= 0: + # Dictionary of last-used tmp labels for a given stream number + tmpInputs = {streamNo: -1 for streamNo in extraFilters} + else: + # Insert blank entries for global filters into extraFilters + # so the per-stream filters know what input to source later + for streamNo in range(len(extraAudio), 0, -1): + if streamNo + 1 not in extraFilters: + extraFilters[streamNo + 1] = [] + # Also filter the primary audio track + extraFilters[1] = [] + tmpInputs = { + streamNo: globalFilters - 1 + for streamNo in extraFilters + } + + # Add the global filters! + # NOTE: list length must = globalFilters, currently hardcoded + if tmpInputs: + extraFilterCommand.extend([ + '[%s:a] ashowinfo [%stmp0]' % ( + str(streamNo), + str(streamNo) + ) + for streamNo in tmpInputs + ]) + + # Now add the per-stream filters! + for streamNo, paramList in extraFilters.items(): + for param in paramList: + source = '[%s:a]' % str(streamNo) \ + if tmpInputs[streamNo] == -1 else \ + '[%stmp%s]' % ( + str(streamNo), str(tmpInputs[streamNo]) + ) + tmpInputs[streamNo] = tmpInputs[streamNo] + 1 + extraFilterCommand.append( + '%s %s%s [%stmp%s]' % ( + source, param[0], param[1], str(streamNo), + str(tmpInputs[streamNo]) + ) + ) + + # Join all the filters together and combine into 1 stream + extraFilterCommand = "; ".join(extraFilterCommand) + '; ' \ + if tmpInputs else '' + ffmpegCommand.extend([ + '-filter_complex', + extraFilterCommand + + '%s amix=inputs=%s:duration=first [a]' + % ( + "".join([ + '[%stmp%s]' % (str(i), tmpInputs[i]) + if i in extraFilters else '[%s:a]' % str(i) + for i in range(1, len(extraAudio) + 2) + ]), + str(len(extraAudio) + 1) + ), + ]) + + # Only map audio from the filters, and video from the pipe + ffmpegCommand.extend([ + '-map', '0:v', + '-map', '[a]', + ]) + + ffmpegCommand.extend([ + # OUTPUT + '-vcodec', vencoder, + '-acodec', aencoder, + '-b:v', vbitrate, + '-b:a', abitrate, + '-pix_fmt', Core.settings.value('outputVideoFormat'), + '-preset', Core.settings.value('outputPreset'), + '-f', container + ]) + + if acodec == 'aac': + ffmpegCommand.append('-strict') + ffmpegCommand.append('-2') + + ffmpegCommand.append(outputFile) + return ffmpegCommand + + +def getAudioDuration(filename): + command = [Core.FFMPEG_BIN, '-i', filename] + + try: + fileInfo = checkOutput(command, stderr=sp.STDOUT) + except sp.CalledProcessError as ex: + fileInfo = ex.output + + info = fileInfo.decode("utf-8").split('\n') + for line in info: + if 'Duration' in line: + d = line.split(',')[0] + d = d.split(' ')[3] + d = d.split(':') + duration = float(d[0])*3600 + float(d[1])*60 + float(d[2]) + return duration + + +def readAudioFile(filename, parent): + duration = getAudioDuration(filename) + + command = [ + Core.FFMPEG_BIN, + '-i', filename, + '-f', 's16le', + '-acodec', 'pcm_s16le', + '-ar', '44100', # ouput will have 44100 Hz + '-ac', '1', # mono (set to '2' for stereo) + '-'] + in_pipe = openPipe( + command, stdout=sp.PIPE, stderr=sp.DEVNULL, bufsize=10**8 + ) + + completeAudioArray = numpy.empty(0, dtype="int16") + + progress = 0 + lastPercent = None + while True: + if Core.canceled: + return + # read 2 seconds of audio + progress += 4 + raw_audio = in_pipe.stdout.read(88200*4) + if len(raw_audio) == 0: + break + audio_array = numpy.fromstring(raw_audio, dtype="int16") + completeAudioArray = numpy.append(completeAudioArray, audio_array) + + percent = int(100*(progress/duration)) + if percent >= 100: + percent = 100 + + if lastPercent != percent: + string = 'Loading audio file: '+str(percent)+'%' + parent.progressBarSetText.emit(string) + parent.progressBarUpdate.emit(percent) + + lastPercent = percent + + in_pipe.kill() + in_pipe.wait() + + # add 0s the end + completeAudioArrayCopy = numpy.zeros( + len(completeAudioArray) + 44100, dtype="int16") + completeAudioArrayCopy[:len(completeAudioArray)] = completeAudioArray + completeAudioArray = completeAudioArrayCopy + + return (completeAudioArray, duration) diff --git a/src/toolkit/frame.py b/src/toolkit/frame.py index cddb611..83fd59e 100644 --- a/src/toolkit/frame.py +++ b/src/toolkit/frame.py @@ -7,9 +7,7 @@ from PIL.ImageQt import ImageQt import sys import os - -class Frame: - '''Controller class for all frames.''' +from toolkit.common import Core class FramePainter(QtGui.QPainter): @@ -59,7 +57,7 @@ def Checkerboard(width, height): ''' image = FloodFrame(1920, 1080, (0, 0, 0, 0)) image.paste(Image.open( - os.path.join(Frame.core.wd, "background.png")), + os.path.join(Core.wd, "background.png")), (0, 0) ) image = image.resize((width, height)) diff --git a/src/video_thread.py b/src/video_thread.py index 1f2eaf5..8517b92 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -5,9 +5,9 @@ are emitted to update MainWindow's progress bar, detail text, and preview. Export can be cancelled with cancel() ''' -from PyQt5 import QtCore, QtGui, uic +from PyQt5 import QtCore, QtGui from PyQt5.QtCore import pyqtSignal, pyqtSlot -from PIL import Image, ImageDraw, ImageFont +from PIL import Image from PIL.ImageQt import ImageQt import numpy import subprocess as sp @@ -19,6 +19,7 @@ import time import signal from toolkit import openPipe +from toolkit.ffmpeg import readAudioFile, createFfmpegCommand from toolkit.frame import Checkerboard @@ -33,7 +34,7 @@ class Worker(QtCore.QObject): def __init__(self, parent, inputFile, outputFile, components): QtCore.QObject.__init__(self) self.core = parent.core - self.settings = parent.core.settings + self.settings = parent.settings self.modules = parent.core.modules parent.createVideo.connect(self.createVideo) @@ -133,12 +134,17 @@ class Worker(QtCore.QObject): # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ self.progressBarSetText.emit("Loading audio file...") - self.completeAudioArray, duration = self.core.readAudioFile( + audioFileTraits = readAudioFile( self.inputFile, self ) + if audioFileTraits is None: + self.cancelExport() + return + self.completeAudioArray, duration = audioFileTraits self.progressBarUpdate.emit(0) self.progressBarSetText.emit("Starting components...") + canceledByComponent = False print('Loaded Components:', ", ".join([ "%s) %s" % (num, str(component)) for num, component in enumerate(reversed(self.components)) @@ -153,14 +159,15 @@ class Worker(QtCore.QObject): progressBarSetText=self.progressBarSetText ) - if 'error' in comp.properties(): + if 'error' in comp.properties: self.cancel() self.canceled = True + canceledByComponent = True errMsg = "Component #%s encountered an error!" % compNo \ - if comp.error() is None else 'Component #%s (%s): %s' % ( + if comp.error is None else 'Component #%s (%s): %s' % ( str(compNo), str(comp), - comp.error() + comp.error ) self.parent.showMessage( msg=errMsg, @@ -168,17 +175,16 @@ class Worker(QtCore.QObject): parent=None # MainWindow is in a different thread ) break - if 'static' in comp.properties(): + if 'static' in comp.properties: self.staticComponents[compNo] = \ comp.frameRender(compNo, 0).copy() if self.canceled: - print('Export cancelled by component #%s (%s): %s' % ( - compNo, str(comp), comp.error() - )) - self.progressBarSetText.emit('Export Canceled') - self.encoding.emit(False) - self.videoCreated.emit() + if canceledByComponent: + print('Export cancelled by component #%s (%s): %s' % ( + compNo, str(comp), comp.error + )) + self.cancelExport() return # Merge consecutive static component frames together @@ -192,8 +198,8 @@ class Worker(QtCore.QObject): ) self.staticComponents[compNo] = None - ffmpegCommand = self.core.createFfmpegCommand( - self.inputFile, self.outputFile, duration + ffmpegCommand = createFfmpegCommand( + self.inputFile, self.outputFile, self.components, duration ) print('###### FFMPEG COMMAND ######\n%s' % " ".join(ffmpegCommand)) print('############################') @@ -280,7 +286,6 @@ class Worker(QtCore.QObject): pass self.progressBarUpdate.emit(0) self.progressBarSetText.emit('Export Canceled') - else: if self.error: print("Export Failed") @@ -297,6 +302,12 @@ class Worker(QtCore.QObject): self.encoding.emit(False) self.videoCreated.emit() + def cancelExport(self): + self.progressBarUpdate.emit(0) + self.progressBarSetText.emit('Export Canceled') + self.encoding.emit(False) + self.videoCreated.emit() + def updateProgress(self, pStr, pVal): self.progressBarValue.emit(pVal) self.progressBarSetText.emit(pStr) -- cgit v1.2.3 From bf0890e7c87c730b8970c1a20c5b6a9a1a55d203 Mon Sep 17 00:00:00 2001 From: tassaron Date: Sun, 23 Jul 2017 01:53:54 -0400 Subject: components auto-connect & track widgets, less autosave spam importing toolkit from live interpreter now works --- setup.py | 2 +- src/__init__.py | 12 +++ src/command.py | 2 - src/component.py | 196 +++++++++++++++++++++++++++++++++------------ src/components/color.py | 137 +++++++++++-------------------- src/components/image.py | 77 +++++------------- src/components/original.py | 59 ++++++-------- src/components/sound.py | 50 +++--------- src/components/text.py | 81 ++++++++----------- src/components/video.py | 98 +++++++---------------- src/core.py | 196 ++++++++++++++++++++++++++++----------------- src/main.py | 23 ++---- src/mainwindow.py | 125 +++++++++++++++++++---------- src/mainwindow.ui | 3 + src/presetmanager.py | 15 ++-- src/preview_thread.py | 17 ++-- src/toolkit/common.py | 56 +++---------- src/toolkit/core.py | 18 ----- src/toolkit/ffmpeg.py | 46 ++++++++--- src/toolkit/frame.py | 4 +- src/video_thread.py | 7 +- 21 files changed, 604 insertions(+), 620 deletions(-) delete mode 100644 src/toolkit/core.py (limited to 'src/components/sound.py') diff --git a/setup.py b/setup.py index a2d8495..d4f226b 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup import os -__version__ = '2.0.0.rc1' +__version__ = '2.0.0.rc2' def package_files(directory): diff --git a/src/__init__.py b/src/__init__.py index 8b13789..2f4cffa 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1 +1,13 @@ +import sys +import os + +if getattr(sys, 'frozen', False): + # frozen + wd = os.path.dirname(sys.executable) +else: + # unfrozen + wd = os.path.dirname(os.path.realpath(__file__)) + +# make relative imports work when using /src as a package +sys.path.insert(0, wd) diff --git a/src/command.py b/src/command.py index 046a1bf..ca186e5 100644 --- a/src/command.py +++ b/src/command.py @@ -10,7 +10,6 @@ import sys import time from core import Core -from toolkit import loadDefaultSettings class Command(QtCore.QObject): @@ -55,7 +54,6 @@ class Command(QtCore.QObject): self.args = self.parser.parse_args() self.settings = Core.settings - loadDefaultSettings(self) if self.args.projpath: projPath = self.args.projpath diff --git a/src/component.py b/src/component.py index 92cc65c..bec2df5 100644 --- a/src/component.py +++ b/src/component.py @@ -5,8 +5,28 @@ from PyQt5 import uic, QtCore, QtWidgets import os -from core import Core -from toolkit.common import getPresetDir +from presetmanager import getPresetDir + + +def commandWrapper(func): + '''Intercepts each component's command() method to check for global args''' + def decorator(self, arg): + if arg.startswith('preset='): + _, preset = arg.split('=', 1) + path = os.path.join(getPresetDir(self), preset) + if not os.path.exists(path): + print('Couldn\'t locate preset "%s"' % preset) + quit(1) + else: + print('Opening "%s" preset on layer %s' % ( + preset, self.compPos) + ) + self.core.openPreset(path, self.compPos, preset) + # Don't call the component's command() method + return + else: + return func(self, arg) + return decorator class ComponentMetaclass(type(QtCore.QObject)): @@ -16,10 +36,14 @@ class ComponentMetaclass(type(QtCore.QObject)): E.g., takes only major version from version string & decorates methods ''' def __new__(cls, name, parents, attrs): - # print('Creating %s component' % attrs['name']) + if 'ui' not in attrs: + # use module name as ui filename by default + attrs['ui'] = '%s.ui' % os.path.splitext( + attrs['__module__'].split('.')[-1] + )[0] # Turn certain class methods into properties and classmethods - for key in ('error', 'properties', 'audio', 'commandHelp'): + for key in ('error', 'properties', 'audio'): if key not in attrs: continue attrs[key] = property(attrs[key]) @@ -29,6 +53,10 @@ class ComponentMetaclass(type(QtCore.QObject)): continue attrs[key] = classmethod(key) + # Do not apply these mutations to the base class + if parents[0] != QtCore.QObject: + attrs['command'] = commandWrapper(attrs['command']) + # Turn version string into a number try: if 'version' not in attrs: @@ -54,19 +82,24 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): ''' name = 'Component' + # ui = 'nameOfNonDefaultUiFile' version = '1.0.0' - # The 1st number (before dot, aka the major version) is used to determine + # The major version (before the first dot) is used to determine # preset compatibility; the rest is ignored so it can be non-numeric. modified = QtCore.pyqtSignal(int, dict) # ^ Signal used to tell core program that the component state changed, # you shouldn't need to use this directly, it is used by self.update() - def __init__(self, moduleIndex, compPos): + def __init__(self, moduleIndex, compPos, core): super().__init__() - self.currentPreset = None self.moduleIndex = moduleIndex self.compPos = compPos + self.core = core + self.currentPreset = None + + self._trackedWidgets = {} + self._presetNames = {} # Stop lengthy processes in response to this variable self.canceled = False @@ -114,28 +147,103 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): ''' return [] - def commandHelp(self): - '''Help text as string for this component's commandline arguments''' - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ # Methods # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - def update(self): - '''Read widget values from self.page, then call super().update()''' - self.parent.drawPreview() - saveValueStore = self.savePreset() - saveValueStore['preset'] = self.currentPreset - self.modified.emit(self.compPos, saveValueStore) + def widget(self, parent): + ''' + Call super().widget(*args) to create the component widget + which also auto-connects any common widgets (e.g., checkBoxes) + to self.update(). Then in a subclass connect special actions + (e.g., pushButtons to select a file/colour) and initialize + ''' + self.parent = parent + self.settings = parent.settings + self.page = self.loadUi(self.__class__.ui) + + # Connect widget signals + widgets = { + 'lineEdit': self.page.findChildren(QtWidgets.QLineEdit), + 'checkBox': self.page.findChildren(QtWidgets.QCheckBox), + 'spinBox': self.page.findChildren(QtWidgets.QSpinBox), + 'comboBox': self.page.findChildren(QtWidgets.QComboBox), + } + widgets['spinBox'].extend( + self.page.findChildren(QtWidgets.QDoubleSpinBox) + ) + for widget in widgets['lineEdit']: + widget.textChanged.connect(self.update) + for widget in widgets['checkBox']: + widget.stateChanged.connect(self.update) + for widget in widgets['spinBox']: + widget.valueChanged.connect(self.update) + for widget in widgets['comboBox']: + widget.currentIndexChanged.connect(self.update) + + def trackWidgets(self, trackDict, presetNames=None): + ''' + Name widgets to track in update(), savePreset(), and loadPreset() + Accepts a dict with attribute names as keys and widgets as values. + Optional: a dict of attribute names to map to preset variable names + ''' + self._trackedWidgets = trackDict + if type(presetNames) is dict: + self._presetNames = presetNames - def loadPreset(self, presetDict, presetName): + def update(self): ''' - Subclasses take (presetDict, presetName=None) as args. - Must use super().loadPreset(presetDict, presetName) first, + Reads all tracked widget values into instance attributes + and tells the MainWindow that the component was modified. + Call at the END of your method if you need to subclass this. + ''' + for attr, widget in self._trackedWidgets.items(): + if type(widget) == QtWidgets.QLineEdit: + setattr(self, attr, widget.text()) + elif type(widget) == QtWidgets.QSpinBox \ + or type(widget) == QtWidgets.QDoubleSpinBox: + setattr(self, attr, widget.value()) + elif type(widget) == QtWidgets.QCheckBox: + setattr(self, attr, widget.isChecked()) + elif type(widget) == QtWidgets.QComboBox: + setattr(self, attr, widget.currentIndex()) + if not self.core.openingProject: + self.parent.drawPreview() + saveValueStore = self.savePreset() + saveValueStore['preset'] = self.currentPreset + self.modified.emit(self.compPos, saveValueStore) + + def loadPreset(self, presetDict, presetName=None): + ''' + Subclasses should take (presetDict, *args) as args. + Must use super().loadPreset(presetDict, *args) first, then update self.page widgets using the preset dict. ''' self.currentPreset = presetName \ if presetName is not None else presetDict['preset'] + for attr, widget in self._trackedWidgets.items(): + val = presetDict[ + attr if attr not in self._presetNames + else self._presetNames[attr] + ] + if type(widget) == QtWidgets.QLineEdit: + widget.setText(val) + elif type(widget) == QtWidgets.QSpinBox \ + or type(widget) == QtWidgets.QDoubleSpinBox: + widget.setValue(val) + elif type(widget) == QtWidgets.QCheckBox: + widget.setChecked(val) + elif type(widget) == QtWidgets.QComboBox: + widget.setCurrentIndex(val) + + def savePreset(self): + saveValueStore = {} + for attr, widget in self._trackedWidgets.items(): + saveValueStore[ + attr if attr not in self._presetNames + else self._presetNames[attr] + ] = getattr(self, attr) + return saveValueStore def preFrameRender(self, **kwargs): ''' @@ -151,34 +259,27 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): for key, value in kwargs.items(): setattr(self, key, value) - def command(self, arg): + def commandHelp(self): + '''Help text as string for this component's commandline arguments''' + + def command(self, arg=''): ''' - Configure a component using argument from the commandline. - Use super().command(arg) at the end of a subclass's method, - if no arguments are found in that method first + Configure a component using an arg from the commandline. This is + never called if global args like 'preset=' are found in the arg. + So simply check for any non-global args in your component and + call super().command() at the end to get a Help message. ''' - if arg.startswith('preset='): - _, preset = arg.split('=', 1) - path = os.path.join(getPresetDir(self), preset) - if not os.path.exists(path): - print('Couldn\'t locate preset "%s"' % preset) - quit(1) - else: - print('Opening "%s" preset on layer %s' % ( - preset, self.compPos) - ) - self.core.openPreset(path, self.compPos, preset) - else: - print( - self.__doc__, 'Usage:\n' - 'Open a preset for this component:\n' - ' "preset=Preset Name"') - print(self.commandHelp) - quit(0) + print( + self.__class__.name, 'Usage:\n' + 'Open a preset for this component:\n' + ' "preset=Preset Name"' + ) + self.commandHelp() + quit(0) def loadUi(self, filename): '''Load a Qt Designer ui file to use for this component's widget''' - return uic.loadUi(os.path.join(Core.componentsPath, filename)) + return uic.loadUi(os.path.join(self.core.componentsPath, filename)) def cancel(self): '''Stop any lengthy process in response to this variable.''' @@ -191,16 +292,9 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): ### Reference methods for creating a new component ### (Inherit from this class and define these) - def widget(self, parent): - self.parent = parent - self.settings = parent.settings - self.page = self.loadUi('example.ui') - # --- connect widget signals here --- - return self.page - def previewRender(self, previewWorker): width = int(self.settings.value('outputWidth')) - height = int(previewWorker.core.settings.value('outputHeight')) + height = int(self.settings.value('outputHeight')) from toolkit.frame import BlankFrame image = BlankFrame(width, height) return image @@ -217,7 +311,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): class BadComponentInit(Exception): ''' - General purpose exception components can raise to indicate + General purpose exception that components can raise to indicate a Python issue with e.g., dynamic creation of instances or something. Decorative for now, may have future use for logging. ''' diff --git a/src/components/color.py b/src/components/color.py index 03371e7..8257ed9 100644 --- a/src/components/color.py +++ b/src/components/color.py @@ -13,18 +13,15 @@ class Component(Component): name = 'Color' version = '1.0.0' - def widget(self, parent): - self.parent = parent - self.settings = parent.settings - page = self.loadUi('color.ui') - + def widget(self, *args): self.color1 = (0, 0, 0) self.color2 = (133, 133, 133) self.x = 0 self.y = 0 + super().widget(*args) - page.lineEdit_color1.setText('%s,%s,%s' % self.color1) - page.lineEdit_color2.setText('%s,%s,%s' % self.color2) + self.page.lineEdit_color1.setText('%s,%s,%s' % self.color1) + self.page.lineEdit_color2.setText('%s,%s,%s' % self.color2) btnStyle1 = "QPushButton { background-color : %s; outline: none; }" \ % QColor(*self.color1).name() @@ -32,68 +29,55 @@ class Component(Component): btnStyle2 = "QPushButton { background-color : %s; outline: none; }" \ % QColor(*self.color2).name() - page.pushButton_color1.setStyleSheet(btnStyle1) - page.pushButton_color2.setStyleSheet(btnStyle2) - page.pushButton_color1.clicked.connect(lambda: self.pickColor(1)) - page.pushButton_color2.clicked.connect(lambda: self.pickColor(2)) + self.page.pushButton_color1.setStyleSheet(btnStyle1) + self.page.pushButton_color2.setStyleSheet(btnStyle2) + self.page.pushButton_color1.clicked.connect(lambda: self.pickColor(1)) + self.page.pushButton_color2.clicked.connect(lambda: self.pickColor(2)) # disable color #2 until non-default 'fill' option gets changed - page.lineEdit_color2.setDisabled(True) - page.pushButton_color2.setDisabled(True) - page.spinBox_x.valueChanged.connect(self.update) - page.spinBox_y.valueChanged.connect(self.update) - page.spinBox_width.setValue( + self.page.lineEdit_color2.setDisabled(True) + self.page.pushButton_color2.setDisabled(True) + self.page.spinBox_width.setValue( int(self.settings.value("outputWidth"))) - page.spinBox_height.setValue( + self.page.spinBox_height.setValue( int(self.settings.value("outputHeight"))) - page.lineEdit_color1.textChanged.connect(self.update) - page.lineEdit_color2.textChanged.connect(self.update) - page.spinBox_x.valueChanged.connect(self.update) - page.spinBox_y.valueChanged.connect(self.update) - page.spinBox_width.valueChanged.connect(self.update) - page.spinBox_height.valueChanged.connect(self.update) - page.checkBox_trans.stateChanged.connect(self.update) - self.fillLabels = [ 'Solid', 'Linear Gradient', 'Radial Gradient', ] for label in self.fillLabels: - page.comboBox_fill.addItem(label) - page.comboBox_fill.setCurrentIndex(0) - page.comboBox_fill.currentIndexChanged.connect(self.update) - page.comboBox_spread.currentIndexChanged.connect(self.update) - page.spinBox_radialGradient_end.valueChanged.connect(self.update) - page.spinBox_radialGradient_start.valueChanged.connect(self.update) - page.spinBox_radialGradient_spread.valueChanged.connect(self.update) - page.spinBox_linearGradient_end.valueChanged.connect(self.update) - page.spinBox_linearGradient_start.valueChanged.connect(self.update) - page.checkBox_stretch.stateChanged.connect(self.update) - - self.page = page - return page + self.page.comboBox_fill.addItem(label) + self.page.comboBox_fill.setCurrentIndex(0) + + self.trackWidgets( + { + 'x': self.page.spinBox_x, + 'y': self.page.spinBox_y, + 'sizeWidth': self.page.spinBox_width, + 'sizeHeight': self.page.spinBox_height, + 'trans': self.page.checkBox_trans, + 'spread': self.page.comboBox_spread, + 'stretch': self.page.checkBox_stretch, + 'RG_start': self.page.spinBox_radialGradient_start, + 'LG_start': self.page.spinBox_linearGradient_start, + 'RG_end': self.page.spinBox_radialGradient_end, + 'LG_end': self.page.spinBox_linearGradient_end, + 'RG_centre': self.page.spinBox_radialGradient_spread, + 'fillType': self.page.comboBox_fill, + }, presetNames={ + 'sizeWidth': 'width', + 'sizeHeight': 'height', + } + ) def update(self): self.color1 = rgbFromString(self.page.lineEdit_color1.text()) self.color2 = rgbFromString(self.page.lineEdit_color2.text()) - self.x = self.page.spinBox_x.value() - self.y = self.page.spinBox_y.value() - self.sizeWidth = self.page.spinBox_width.value() - self.sizeHeight = self.page.spinBox_height.value() - self.trans = self.page.checkBox_trans.isChecked() - self.spread = self.page.comboBox_spread.currentIndex() - - self.RG_start = self.page.spinBox_radialGradient_start.value() - self.RG_end = self.page.spinBox_radialGradient_end.value() - self.RG_centre = self.page.spinBox_radialGradient_spread.value() - self.stretch = self.page.checkBox_stretch.isChecked() - self.LG_start = self.page.spinBox_linearGradient_start.value() - self.LG_end = self.page.spinBox_linearGradient_end.value() - - self.fillType = self.page.comboBox_fill.currentIndex() - if self.fillType == 0: + + fillType = self.page.comboBox_fill.currentIndex() + if fillType == 0: self.page.lineEdit_color2.setEnabled(False) self.page.pushButton_color2.setEnabled(False) self.page.checkBox_trans.setEnabled(False) @@ -105,10 +89,10 @@ class Component(Component): self.page.checkBox_trans.setEnabled(True) self.page.checkBox_stretch.setEnabled(True) self.page.comboBox_spread.setEnabled(True) - if self.trans: + if self.page.checkBox_trans.isChecked(): self.page.lineEdit_color2.setEnabled(False) self.page.pushButton_color2.setEnabled(False) - self.page.fillWidget.setCurrentIndex(self.fillType) + self.page.fillWidget.setCurrentIndex(fillType) super().update() @@ -181,25 +165,11 @@ class Component(Component): return image.finalize() - def loadPreset(self, pr, presetName=None): - super().loadPreset(pr, presetName) + def loadPreset(self, pr, *args): + super().loadPreset(pr, *args) - self.page.comboBox_fill.setCurrentIndex(pr['fillType']) self.page.lineEdit_color1.setText('%s,%s,%s' % pr['color1']) self.page.lineEdit_color2.setText('%s,%s,%s' % pr['color2']) - self.page.spinBox_x.setValue(pr['x']) - self.page.spinBox_y.setValue(pr['y']) - self.page.spinBox_width.setValue(pr['width']) - self.page.spinBox_height.setValue(pr['height']) - self.page.checkBox_trans.setChecked(pr['trans']) - - self.page.spinBox_radialGradient_start.setValue(pr['RG_start']) - self.page.spinBox_radialGradient_end.setValue(pr['RG_end']) - self.page.spinBox_radialGradient_spread.setValue(pr['RG_centre']) - self.page.spinBox_linearGradient_start.setValue(pr['LG_start']) - self.page.spinBox_linearGradient_end.setValue(pr['LG_end']) - self.page.checkBox_stretch.setChecked(pr['stretch']) - self.page.comboBox_spread.setCurrentIndex(pr['spread']) btnStyle1 = "QPushButton { background-color : %s; outline: none; }" \ % QColor(*pr['color1']).name() @@ -209,23 +179,10 @@ class Component(Component): self.page.pushButton_color2.setStyleSheet(btnStyle2) def savePreset(self): - return { - 'color1': self.color1, - 'color2': self.color2, - 'x': self.x, - 'y': self.y, - 'fillType': self.fillType, - 'width': self.sizeWidth, - 'height': self.sizeHeight, - 'trans': self.trans, - 'stretch': self.stretch, - 'spread': self.spread, - 'RG_start': self.RG_start, - 'RG_end': self.RG_end, - 'RG_centre': self.RG_centre, - 'LG_start': self.LG_start, - 'LG_end': self.LG_end, - } + saveValueStore = super().savePreset() + saveValueStore['color1'] = self.color1 + saveValueStore['color2'] = self.color2 + return saveValueStore def pickColor(self, num): RGBstring, btnStyle = pickColor() @@ -242,7 +199,7 @@ class Component(Component): print('Specify a color:\n color=255,255,255') def command(self, arg): - if not arg.startswith('preset=') and '=' in arg: + if '=' in arg: key, arg = arg.split('=', 1) if key == 'color': self.page.lineEdit_color1.setText(arg) diff --git a/src/components/image.py b/src/components/image.py index 591e03e..a705904 100644 --- a/src/components/image.py +++ b/src/components/image.py @@ -2,7 +2,6 @@ from PIL import Image, ImageDraw, ImageEnhance from PyQt5 import QtGui, QtCore, QtWidgets import os -from core import Core from component import Component from toolkit.frame import BlankFrame @@ -11,35 +10,26 @@ class Component(Component): name = 'Image' version = '1.0.0' - def widget(self, parent): - self.parent = parent - self.settings = parent.settings - page = self.loadUi('image.ui') - - page.lineEdit_image.textChanged.connect(self.update) - page.pushButton_image.clicked.connect(self.pickImage) - page.spinBox_scale.valueChanged.connect(self.update) - page.spinBox_rotate.valueChanged.connect(self.update) - page.spinBox_color.valueChanged.connect(self.update) - page.checkBox_stretch.stateChanged.connect(self.update) - page.checkBox_mirror.stateChanged.connect(self.update) - page.spinBox_x.valueChanged.connect(self.update) - page.spinBox_y.valueChanged.connect(self.update) - - self.page = page - return page - - def update(self): - self.imagePath = self.page.lineEdit_image.text() - self.scale = self.page.spinBox_scale.value() - self.rotate = self.page.spinBox_rotate.value() - self.color = self.page.spinBox_color.value() - self.xPosition = self.page.spinBox_x.value() - self.yPosition = self.page.spinBox_y.value() - self.stretched = self.page.checkBox_stretch.isChecked() - self.mirror = self.page.checkBox_mirror.isChecked() - - super().update() + def widget(self, *args): + super().widget(*args) + self.page.pushButton_image.clicked.connect(self.pickImage) + self.trackWidgets( + { + 'imagePath': self.page.lineEdit_image, + 'scale': self.page.spinBox_scale, + 'rotate': self.page.spinBox_rotate, + 'color': self.page.spinBox_color, + 'xPosition': self.page.spinBox_x, + 'yPosition': self.page.spinBox_y, + 'stretched': self.page.checkBox_stretch, + 'mirror': self.page.checkBox_mirror, + }, + presetNames={ + 'imagePath': 'image', + 'xPosition': 'x', + 'yPosition': 'y', + }, + ) def previewRender(self, previewWorker): width = int(self.settings.value('outputWidth')) @@ -89,41 +79,18 @@ class Component(Component): return frame - def loadPreset(self, pr, presetName=None): - super().loadPreset(pr, presetName) - self.page.lineEdit_image.setText(pr['image']) - self.page.spinBox_scale.setValue(pr['scale']) - self.page.spinBox_color.setValue(pr['color']) - self.page.spinBox_rotate.setValue(pr['rotate']) - self.page.spinBox_x.setValue(pr['x']) - self.page.spinBox_y.setValue(pr['y']) - self.page.checkBox_stretch.setChecked(pr['stretched']) - self.page.checkBox_mirror.setChecked(pr['mirror']) - - def savePreset(self): - return { - 'image': self.imagePath, - 'scale': self.scale, - 'color': self.color, - 'rotate': self.rotate, - 'stretched': self.stretched, - 'mirror': self.mirror, - 'x': self.xPosition, - 'y': self.yPosition, - } - def pickImage(self): imgDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( self.page, "Choose Image", imgDir, - "Image Files (%s)" % " ".join(Core.imageFormats)) + "Image Files (%s)" % " ".join(self.core.imageFormats)) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.page.lineEdit_image.setText(filename) self.update() def command(self, arg): - if not arg.startswith('preset=') and '=' in arg: + if '=' in arg: key, arg = arg.split('=', 1) if key == 'path' and os.path.exists(arg): try: diff --git a/src/components/original.py b/src/components/original.py index ae40df3..2bda878 100644 --- a/src/components/original.py +++ b/src/components/original.py @@ -18,59 +18,46 @@ class Component(Component): def names(): return ['Original Audio Visualization'] - def widget(self, parent): - self.parent = parent - self.settings = parent.settings + def widget(self, *args): self.visColor = (255, 255, 255) self.scale = 20 self.y = 0 - self.canceled = False - - page = self.loadUi('original.ui') - page.comboBox_visLayout.addItem("Classic") - page.comboBox_visLayout.addItem("Split") - page.comboBox_visLayout.addItem("Bottom") - page.comboBox_visLayout.addItem("Top") - page.comboBox_visLayout.setCurrentIndex(0) - page.comboBox_visLayout.currentIndexChanged.connect(self.update) - page.lineEdit_visColor.setText('%s,%s,%s' % self.visColor) - page.pushButton_visColor.clicked.connect(lambda: self.pickColor()) + super().widget(*args) + + self.page.comboBox_visLayout.addItem("Classic") + self.page.comboBox_visLayout.addItem("Split") + self.page.comboBox_visLayout.addItem("Bottom") + self.page.comboBox_visLayout.addItem("Top") + self.page.comboBox_visLayout.setCurrentIndex(0) + + self.page.lineEdit_visColor.setText('%s,%s,%s' % self.visColor) + self.page.pushButton_visColor.clicked.connect(lambda: self.pickColor()) btnStyle = "QPushButton { background-color : %s; outline: none; }" \ % QColor(*self.visColor).name() - page.pushButton_visColor.setStyleSheet(btnStyle) - page.lineEdit_visColor.textChanged.connect(self.update) - page.spinBox_scale.valueChanged.connect(self.update) - page.spinBox_y.valueChanged.connect(self.update) + self.page.pushButton_visColor.setStyleSheet(btnStyle) - self.page = page - return page + self.trackWidgets({ + 'layout': self.page.comboBox_visLayout, + 'scale': self.page.spinBox_scale, + 'y': self.page.spinBox_y, + }) def update(self): - self.layout = self.page.comboBox_visLayout.currentIndex() self.visColor = rgbFromString(self.page.lineEdit_visColor.text()) - self.scale = self.page.spinBox_scale.value() - self.y = self.page.spinBox_y.value() - super().update() - def loadPreset(self, pr, presetName=None): - super().loadPreset(pr, presetName) + def loadPreset(self, pr, *args): + super().loadPreset(pr, *args) self.page.lineEdit_visColor.setText('%s,%s,%s' % pr['visColor']) btnStyle = "QPushButton { background-color : %s; outline: none; }" \ % QColor(*pr['visColor']).name() self.page.pushButton_visColor.setStyleSheet(btnStyle) - self.page.comboBox_visLayout.setCurrentIndex(pr['layout']) - self.page.spinBox_scale.setValue(pr['scale']) - self.page.spinBox_y.setValue(pr['y']) def savePreset(self): - return { - 'layout': self.layout, - 'visColor': self.visColor, - 'scale': self.scale, - 'y': self.y, - } + saveValueStore = super().savePreset() + saveValueStore['visColor'] = self.visColor + return saveValueStore def previewRender(self, previewWorker): spectrum = numpy.fromfunction( @@ -206,7 +193,7 @@ class Component(Component): return im def command(self, arg): - if not arg.startswith('preset=') and '=' in arg: + if '=' in arg: key, arg = arg.split('=', 1) try: if key == 'color': diff --git a/src/components/sound.py b/src/components/sound.py index 677a22f..dd3cbab 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -10,26 +10,15 @@ class Component(Component): name = 'Sound' version = '1.0.0' - def widget(self, parent): - self.parent = parent - self.settings = parent.settings - page = self.loadUi('sound.ui') - - page.lineEdit_sound.textChanged.connect(self.update) - page.pushButton_sound.clicked.connect(self.pickSound) - page.checkBox_chorus.stateChanged.connect(self.update) - page.spinBox_delay.valueChanged.connect(self.update) - page.spinBox_volume.valueChanged.connect(self.update) - - self.page = page - return page - - def update(self): - self.sound = self.page.lineEdit_sound.text() - self.delay = self.page.spinBox_delay.value() - self.volume = self.page.spinBox_volume.value() - self.chorus = self.page.checkBox_chorus.isChecked() - super().update() + def widget(self, *args): + super().widget(*args) + self.page.pushButton_sound.clicked.connect(self.pickSound) + self.trackWidgets({ + 'sound': self.page.lineEdit_sound, + 'chorus': self.page.checkBox_chorus, + 'delay': self.page.spinBox_delay, + 'volume': self.page.spinBox_volume, + }) def previewRender(self, previewWorker): width = int(self.settings.value('outputWidth')) @@ -67,7 +56,7 @@ class Component(Component): sndDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( self.page, "Choose Sound", sndDir, - "Audio Files (%s)" % " ".join(Core.audioFormats)) + "Audio Files (%s)" % " ".join(self.core.audioFormats)) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.page.lineEdit_sound.setText(filename) @@ -78,30 +67,15 @@ class Component(Component): height = int(self.settings.value('outputHeight')) return BlankFrame(width, height) - def loadPreset(self, pr, presetName=None): - super().loadPreset(pr, presetName) - self.page.lineEdit_sound.setText(pr['sound']) - self.page.checkBox_chorus.setChecked(pr['chorus']) - self.page.spinBox_delay.setValue(pr['delay']) - self.page.spinBox_volume.setValue(pr['volume']) - - def savePreset(self): - return { - 'sound': self.sound, - 'chorus': self.chorus, - 'delay': self.delay, - 'volume': self.volume, - } - def commandHelp(self): print('Path to audio file:\n path=/filepath/to/sound.ogg') def command(self, arg): - if not arg.startswith('preset=') and '=' in arg: + if '=' in arg: key, arg = arg.split('=', 1) if key == 'path': if '*%s' % os.path.splitext(arg)[1] \ - not in Core.audioFormats: + not in self.core.audioFormats: print("Not a supported audio format") quit(1) self.page.lineEdit_sound.setText(arg) diff --git a/src/components/text.py b/src/components/text.py index d511f22..1d64617 100644 --- a/src/components/text.py +++ b/src/components/text.py @@ -16,12 +16,10 @@ class Component(Component): super().__init__(*args) self.titleFont = QFont() - def widget(self, parent): - self.parent = parent - self.settings = parent.settings + def widget(self, *args): + super().widget(*args) height = int(self.settings.value('outputHeight')) width = int(self.settings.value('outputWidth')) - self.textColor = (255, 255, 255) self.title = 'Text' self.alignment = 1 @@ -30,40 +28,35 @@ class Component(Component): self.xPosition = width / 2 - fm.width(self.title)/2 self.yPosition = height / 2 * 1.036 - page = self.loadUi('text.ui') - page.comboBox_textAlign.addItem("Left") - page.comboBox_textAlign.addItem("Middle") - page.comboBox_textAlign.addItem("Right") + self.page.comboBox_textAlign.addItem("Left") + self.page.comboBox_textAlign.addItem("Middle") + self.page.comboBox_textAlign.addItem("Right") - page.lineEdit_textColor.setText('%s,%s,%s' % self.textColor) - page.pushButton_textColor.clicked.connect(self.pickColor) + self.page.lineEdit_textColor.setText('%s,%s,%s' % self.textColor) + self.page.pushButton_textColor.clicked.connect(self.pickColor) btnStyle = "QPushButton { background-color : %s; outline: none; }" \ % QColor(*self.textColor).name() - page.pushButton_textColor.setStyleSheet(btnStyle) - - page.lineEdit_title.setText(self.title) - page.comboBox_textAlign.setCurrentIndex(int(self.alignment)) - page.spinBox_fontSize.setValue(int(self.fontSize)) - page.spinBox_xTextAlign.setValue(int(self.xPosition)) - page.spinBox_yTextAlign.setValue(int(self.yPosition)) - - page.fontComboBox_titleFont.currentFontChanged.connect(self.update) - page.lineEdit_title.textChanged.connect(self.update) - page.comboBox_textAlign.currentIndexChanged.connect(self.update) - page.spinBox_xTextAlign.valueChanged.connect(self.update) - page.spinBox_yTextAlign.valueChanged.connect(self.update) - page.spinBox_fontSize.valueChanged.connect(self.update) - page.lineEdit_textColor.textChanged.connect(self.update) - self.page = page - return page + self.page.pushButton_textColor.setStyleSheet(btnStyle) + + self.page.lineEdit_title.setText(self.title) + self.page.comboBox_textAlign.setCurrentIndex(int(self.alignment)) + self.page.spinBox_fontSize.setValue(int(self.fontSize)) + self.page.spinBox_xTextAlign.setValue(int(self.xPosition)) + self.page.spinBox_yTextAlign.setValue(int(self.yPosition)) + + self.page.fontComboBox_titleFont.currentFontChanged.connect( + self.update + ) + self.trackWidgets({ + 'title': self.page.lineEdit_title, + 'alignment': self.page.comboBox_textAlign, + 'fontSize': self.page.spinBox_fontSize, + 'xPosition': self.page.spinBox_xTextAlign, + 'yPosition': self.page.spinBox_yTextAlign, + }) def update(self): - self.title = self.page.lineEdit_title.text() - self.alignment = self.page.comboBox_textAlign.currentIndex() self.titleFont = self.page.fontComboBox_titleFont.currentFont() - self.fontSize = self.page.spinBox_fontSize.value() - self.xPosition = self.page.spinBox_xTextAlign.value() - self.yPosition = self.page.spinBox_yTextAlign.value() self.textColor = rgbFromString( self.page.lineEdit_textColor.text()) btnStyle = "QPushButton { background-color : %s; outline: none; }" \ @@ -87,32 +80,22 @@ class Component(Component): x = self.xPosition - offset return x, self.yPosition - def loadPreset(self, pr, presetName=None): - super().loadPreset(pr, presetName) + def loadPreset(self, pr, *args): + super().loadPreset(pr, *args) - self.page.lineEdit_title.setText(pr['title']) font = QFont() font.fromString(pr['titleFont']) self.page.fontComboBox_titleFont.setCurrentFont(font) - self.page.spinBox_fontSize.setValue(pr['fontSize']) - self.page.comboBox_textAlign.setCurrentIndex(pr['alignment']) - self.page.spinBox_xTextAlign.setValue(pr['xPosition']) - self.page.spinBox_yTextAlign.setValue(pr['yPosition']) self.page.lineEdit_textColor.setText('%s,%s,%s' % pr['textColor']) btnStyle = "QPushButton { background-color : %s; outline: none; }" \ % QColor(*pr['textColor']).name() self.page.pushButton_textColor.setStyleSheet(btnStyle) def savePreset(self): - return { - 'title': self.title, - 'titleFont': self.titleFont.toString(), - 'alignment': self.alignment, - 'fontSize': self.fontSize, - 'xPosition': self.xPosition, - 'yPosition': self.yPosition, - 'textColor': self.textColor - } + saveValueStore = super().savePreset() + saveValueStore['titleFont'] = self.titleFont.toString() + saveValueStore['textColor'] = self.textColor + return saveValueStore def previewRender(self, previewWorker): width = int(self.settings.value('outputWidth')) @@ -158,7 +141,7 @@ class Component(Component): print('Set custom x, y position:\n x=500 y=500') def command(self, arg): - if not arg.startswith('preset=') and '=' in arg: + if '=' in arg: key, arg = arg.split('=', 1) if key == 'color': self.page.lineEdit_textColor.setText(arg) diff --git a/src/components/video.py b/src/components/video.py index 8758b12..677e3ee 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -9,6 +9,7 @@ from queue import PriorityQueue from core import Core from component import Component, BadComponentInit from toolkit.frame import BlankFrame +from toolkit.ffmpeg import testAudioStream from toolkit import openPipe, checkOutput @@ -16,7 +17,7 @@ class Video: '''Video Component Frame-Fetcher''' def __init__(self, **kwargs): mandatoryArgs = [ - 'ffmpeg', # path to ffmpeg, usually Core.FFMPEG_BIN + 'ffmpeg', # path to ffmpeg, usually self.core.FFMPEG_BIN 'videoPath', 'width', 'height', @@ -110,47 +111,40 @@ class Component(Component): name = 'Video' version = '1.0.0' - def widget(self, parent): - self.parent = parent - self.settings = parent.settings - page = self.loadUi('video.ui') + def widget(self, *args): self.videoPath = '' self.badVideo = False self.badAudio = False self.x = 0 self.y = 0 self.loopVideo = False - - page.lineEdit_video.textChanged.connect(self.update) - page.pushButton_video.clicked.connect(self.pickVideo) - page.checkBox_loop.stateChanged.connect(self.update) - page.checkBox_distort.stateChanged.connect(self.update) - page.checkBox_useAudio.stateChanged.connect(self.update) - page.spinBox_scale.valueChanged.connect(self.update) - page.spinBox_volume.valueChanged.connect(self.update) - page.spinBox_x.valueChanged.connect(self.update) - page.spinBox_y.valueChanged.connect(self.update) - - self.page = page - return page + super().widget(*args) + self.page.pushButton_video.clicked.connect(self.pickVideo) + self.trackWidgets( + { + 'videoPath': self.page.lineEdit_video, + 'loopVideo': self.page.checkBox_loop, + 'useAudio': self.page.checkBox_useAudio, + 'distort': self.page.checkBox_distort, + 'scale': self.page.spinBox_scale, + 'volume': self.page.spinBox_volume, + 'xPosition': self.page.spinBox_x, + 'yPosition': self.page.spinBox_y, + }, presetNames={ + 'videoPath': 'video', + 'loopVideo': 'loop', + 'xPosition': 'x', + 'yPosition': 'y', + } + ) def update(self): - self.videoPath = self.page.lineEdit_video.text() - self.loopVideo = self.page.checkBox_loop.isChecked() - self.useAudio = self.page.checkBox_useAudio.isChecked() - self.distort = self.page.checkBox_distort.isChecked() - self.scale = self.page.spinBox_scale.value() - self.volume = self.page.spinBox_volume.value() - self.xPosition = self.page.spinBox_x.value() - self.yPosition = self.page.spinBox_y.value() - - if self.useAudio: + if self.page.checkBox_useAudio.isChecked(): self.page.label_volume.setEnabled(True) self.page.spinBox_volume.setEnabled(True) else: self.page.label_volume.setEnabled(False) self.page.spinBox_volume.setEnabled(False) - super().update() def previewRender(self, previewWorker): @@ -188,18 +182,7 @@ class Component(Component): return "The video selected is corrupt!" def testAudioStream(self): - # test if an audio stream really exists - audioTestCommand = [ - Core.FFMPEG_BIN, - '-i', self.videoPath, - '-vn', '-f', 'null', '-' - ] - try: - checkOutput(audioTestCommand, stderr=subprocess.DEVNULL) - except subprocess.CalledProcessError: - self.badAudio = True - else: - self.badAudio = False + self.badAudio = testAudioStream(self.videoPath) def audio(self): params = {} @@ -214,7 +197,7 @@ class Component(Component): self.blankFrame_ = BlankFrame(width, height) self.updateChunksize(width, height) self.video = Video( - ffmpeg=Core.FFMPEG_BIN, videoPath=self.videoPath, + ffmpeg=self.core.FFMPEG_BIN, videoPath=self.videoPath, width=width, height=height, chunkSize=self.chunkSize, frameRate=int(self.settings.value("outputFrameRate")), parent=self.parent, loopVideo=self.loopVideo, @@ -227,34 +210,11 @@ class Component(Component): else: return self.blankFrame_ - def loadPreset(self, pr, presetName=None): - super().loadPreset(pr, presetName) - self.page.lineEdit_video.setText(pr['video']) - self.page.checkBox_loop.setChecked(pr['loop']) - self.page.checkBox_useAudio.setChecked(pr['useAudio']) - self.page.checkBox_distort.setChecked(pr['distort']) - self.page.spinBox_scale.setValue(pr['scale']) - self.page.spinBox_volume.setValue(pr['volume']) - self.page.spinBox_x.setValue(pr['x']) - self.page.spinBox_y.setValue(pr['y']) - - def savePreset(self): - return { - 'video': self.videoPath, - 'loop': self.loopVideo, - 'useAudio': self.useAudio, - 'distort': self.distort, - 'scale': self.scale, - 'volume': self.volume, - 'x': self.xPosition, - 'y': self.yPosition, - } - def pickVideo(self): imgDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( self.page, "Choose Video", - imgDir, "Video Files (%s)" % " ".join(Core.videoFormats) + imgDir, "Video Files (%s)" % " ".join(self.core.videoFormats) ) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) @@ -266,7 +226,7 @@ class Component(Component): return command = [ - self.parent.core.FFMPEG_BIN, + self.core.FFMPEG_BIN, '-thread_queue_size', '512', '-i', self.videoPath, '-f', 'image2pipe', @@ -294,10 +254,10 @@ class Component(Component): self.chunkSize = 4*width*height def command(self, arg): - if not arg.startswith('preset=') and '=' in arg: + if '=' in arg: key, arg = arg.split('=', 1) if key == 'path' and os.path.exists(arg): - if '*%s' % os.path.splitext(arg)[1] in Core.videoFormats: + if '*%s' % os.path.splitext(arg)[1] in self.core.videoFormats: self.page.lineEdit_video.setText(arg) self.page.spinBox_scale.setValue(100) self.page.checkBox_loop.setChecked(True) diff --git a/src/core.py b/src/core.py index f6cf5eb..eb6398b 100644 --- a/src/core.py +++ b/src/core.py @@ -1,5 +1,6 @@ ''' Home to the Core class which tracks program state. Used by GUI & commandline + to create a list of components and create a video thread to export. ''' from PyQt5 import QtCore, QtGui, uic import sys @@ -8,7 +9,6 @@ import json from importlib import import_module import toolkit -from toolkit.ffmpeg import findFfmpeg import video_thread @@ -16,82 +16,21 @@ class Core: ''' MainWindow and Command module both use an instance of this class to store the core program state. This object tracks the components, - talks to the components and handles opening/creating project files - and presets. The class also stores constants as class variables. + talks to the components, handles opening/creating project files + and presets, and creates the video thread to export. + This class also stores constants as class variables. ''' - @classmethod - def storeSettings(cls): - '''Store settings/paths to directories as class variables.''' - if getattr(sys, 'frozen', False): - # frozen - wd = os.path.dirname(sys.executable) - else: - wd = os.path.dirname(os.path.realpath(__file__)) - - dataDir = QtCore.QStandardPaths.writableLocation( - QtCore.QStandardPaths.AppConfigLocation - ) - with open(os.path.join(wd, 'encoder-options.json')) as json_file: - encoderOptions = json.load(json_file) - - settings = { - 'wd': wd, - 'dataDir': dataDir, - 'settings': QtCore.QSettings( - os.path.join(dataDir, 'settings.ini'), - QtCore.QSettings.IniFormat), - 'presetDir': os.path.join(dataDir, 'presets'), - 'componentsPath': os.path.join(wd, 'components'), - 'encoderOptions': encoderOptions, - 'FFMPEG_BIN': findFfmpeg(), - 'canceled': False, - } - - settings['videoFormats'] = toolkit.appendUppercase([ - '*.mp4', - '*.mov', - '*.mkv', - '*.avi', - '*.webm', - '*.flv', - ]) - settings['audioFormats'] = toolkit.appendUppercase([ - '*.mp3', - '*.wav', - '*.ogg', - '*.fla', - '*.flac', - '*.aac', - ]) - settings['imageFormats'] = toolkit.appendUppercase([ - '*.png', - '*.jpg', - '*.tif', - '*.tiff', - '*.gif', - '*.bmp', - '*.ico', - '*.xbm', - '*.xpm', - ]) - - # Register all settings as class variables - for classvar, val in settings.items(): - setattr(cls, classvar, val) - # Make settings accessible to the toolkit package - toolkit.init(settings) - def __init__(self): - Core.storeSettings() - self.findComponents() self.selectedComponents = [] self.savedPresets = {} # copies of presets to detect modification + self.openingProject = False def findComponents(self): + '''Imports all the component modules''' def findComponents(): - for f in sorted(os.listdir(Core.componentsPath)): + for f in os.listdir(Core.componentsPath): name, ext = os.path.splitext(f) if name.startswith("__"): continue @@ -104,8 +43,13 @@ class Core: # store canonical module names and indexes self.moduleIndexes = [i for i in range(len(self.modules))] self.compNames = [mod.Component.name for mod in self.modules] - self.altCompNames = [] + # alphabetize modules by Component name + sortedModules = sorted(zip(self.compNames, self.modules)) + self.compNames = [y[0] for y in sortedModules] + self.modules = [y[1] for y in sortedModules] + # store alternative names for modules + self.altCompNames = [] for i, mod in enumerate(self.modules): if hasattr(mod.Component, 'names'): for name in mod.Component.names(): @@ -116,14 +60,17 @@ class Core: component.compPos = i def insertComponent(self, compPos, moduleIndex, loader): - '''Creates a new component''' + ''' + Creates a new component using these args: + (compPos, moduleIndex in self.modules, MWindow/Command/Core obj) + ''' if compPos < 0 or compPos > len(self.selectedComponents): compPos = len(self.selectedComponents) if len(self.selectedComponents) > 50: return None component = self.modules[moduleIndex].Component( - moduleIndex, compPos + moduleIndex, compPos, self ) self.selectedComponents.insert( compPos, @@ -206,6 +153,7 @@ class Core: errcode, data = self.parseAvFile(filepath) if errcode == 0: + self.openingProject = True try: if hasattr(loader, 'window'): for widget, value in data['WindowFields']: @@ -239,7 +187,8 @@ class Core: i = self.insertComponent( -1, self.moduleIndexFor(name), - loader) + loader + ) if i is None: loader.showMessage(msg="Too many components!") break @@ -284,6 +233,7 @@ class Core: showCancel=False, icon='Warning', detail=msg) + self.openingProject = False def parseAvFile(self, filepath): '''Parses an avp (project) or avl (preset package) file. @@ -467,8 +417,106 @@ class Core: def cancel(self): Core.canceled = True - toolkit.cancel() def reset(self): Core.canceled = False - toolkit.reset() + + @classmethod + def storeSettings(cls): + '''Store settings/paths to directories as class variables''' + from __init__ import wd + from toolkit.ffmpeg import findFfmpeg + + cls.wd = wd + dataDir = QtCore.QStandardPaths.writableLocation( + QtCore.QStandardPaths.AppConfigLocation + ) + with open(os.path.join(wd, 'encoder-options.json')) as json_file: + encoderOptions = json.load(json_file) + + settings = { + 'dataDir': dataDir, + 'settings': QtCore.QSettings( + os.path.join(dataDir, 'settings.ini'), + QtCore.QSettings.IniFormat), + 'presetDir': os.path.join(dataDir, 'presets'), + 'componentsPath': os.path.join(wd, 'components'), + 'encoderOptions': encoderOptions, + 'resolutions': [ + '1920x1080', + '1280x720', + '854x480', + ], + 'windowHasFocus': False, + 'FFMPEG_BIN': findFfmpeg(), + 'canceled': False, + } + + settings['videoFormats'] = toolkit.appendUppercase([ + '*.mp4', + '*.mov', + '*.mkv', + '*.avi', + '*.webm', + '*.flv', + ]) + settings['audioFormats'] = toolkit.appendUppercase([ + '*.mp3', + '*.wav', + '*.ogg', + '*.fla', + '*.flac', + '*.aac', + ]) + settings['imageFormats'] = toolkit.appendUppercase([ + '*.png', + '*.jpg', + '*.tif', + '*.tiff', + '*.gif', + '*.bmp', + '*.ico', + '*.xbm', + '*.xpm', + ]) + + # Register all settings as class variables + for classvar, val in settings.items(): + setattr(cls, classvar, val) + + cls.loadDefaultSettings() + + @classmethod + def loadDefaultSettings(cls): + defaultSettings = { + "outputWidth": 1280, + "outputHeight": 720, + "outputFrameRate": 30, + "outputAudioCodec": "AAC", + "outputAudioBitrate": "192", + "outputVideoCodec": "H264", + "outputVideoBitrate": "2500", + "outputVideoFormat": "yuv420p", + "outputPreset": "medium", + "outputFormat": "mp4", + "outputContainer": "MP4", + "projectDir": os.path.join(cls.dataDir, 'projects'), + "pref_insertCompAtTop": True, + } + + for parm, value in defaultSettings.items(): + if cls.settings.value(parm) is None: + cls.settings.setValue(parm, value) + + # Allow manual editing of prefs. (Surprisingly necessary as Qt seems to + # store True as 'true' but interprets a manually-added 'true' as str.) + for key in cls.settings.allKeys(): + if not key.startswith('pref_'): + continue + val = cls.settings.value(key) + if val in ('true', 'false'): + cls.settings.setValue(key, True if val == 'true' else False) + + +# always store settings in class variables even if a Core object is not created +Core.storeSettings() diff --git a/src/main.py b/src/main.py index 6a9a25e..977da3b 100644 --- a/src/main.py +++ b/src/main.py @@ -2,22 +2,17 @@ from PyQt5 import uic, QtWidgets import sys import os +from __init__ import wd -def main(): - if getattr(sys, 'frozen', False): - # frozen - wd = os.path.dirname(sys.executable) - else: - # unfrozen - wd = os.path.dirname(os.path.realpath(__file__)) - # make local imports work everywhere - sys.path.insert(0, wd) +def main(): + app = QtWidgets.QApplication(sys.argv) + app.setApplicationName("audio-visualizer") + # Determine mode mode = 'GUI' if len(sys.argv) > 2: mode = 'commandline' - elif len(sys.argv) == 2: if sys.argv[1].startswith('-'): mode = 'commandline' @@ -28,11 +23,7 @@ def main(): # normal gui launch proj = None - print('Starting Audio Visualizer in %s mode' % mode) - app = QtWidgets.QApplication(sys.argv) - app.setApplicationName("audio-visualizer") - # app.setOrganizationName("audio-visualizer") - + # Launch program if mode == 'commandline': from command import Command @@ -61,9 +52,7 @@ def main(): signal.signal(signal.SIGINT, main.cleanUp) atexit.register(main.cleanUp) - # applicable to both modes sys.exit(app.exec_()) - if __name__ == "__main__": main() diff --git a/src/mainwindow.py b/src/mainwindow.py index 2d598ae..f333513 100644 --- a/src/mainwindow.py +++ b/src/mainwindow.py @@ -17,7 +17,7 @@ import time from core import Core import preview_thread from presetmanager import PresetManager -from toolkit import loadDefaultSettings, disableWhenEncoding, checkOutput +from toolkit import disableWhenEncoding, disableWhenOpeningProject, checkOutput class PreviewWindow(QtWidgets.QLabel): @@ -25,6 +25,7 @@ class PreviewWindow(QtWidgets.QLabel): Paints the preview QLabel and maintains the aspect ratio when the window is resized. ''' + def __init__(self, parent, img): super(PreviewWindow, self).__init__() self.parent = parent @@ -49,6 +50,14 @@ class PreviewWindow(QtWidgets.QLabel): self.pixmap = QtGui.QPixmap(img) self.repaint() + @QtCore.pyqtSlot(str) + def threadError(self, msg): + self.parent.showMessage( + msg=msg, + icon='Warning', + parent=self + ) + class MainWindow(QtWidgets.QMainWindow): ''' @@ -66,13 +75,16 @@ class MainWindow(QtWidgets.QMainWindow): def __init__(self, window, project): QtWidgets.QMainWindow.__init__(self) - # print('main thread id: {}'.format(QtCore.QThread.currentThreadId())) self.window = window self.core = Core() - self.pages = [] # widgets of component settings + # widgets of component settings + self.pages = [] self.lastAutosave = time.time() + # list of previous five autosave times, used to reduce update spam + self.autosaveTimes = [] + self.autosaveCooldown = 0.2 self.encoding = False # Create data directory, load/create settings @@ -80,7 +92,6 @@ class MainWindow(QtWidgets.QMainWindow): self.presetDir = Core.presetDir self.autosavePath = os.path.join(self.dataDir, 'autosave.avp') self.settings = Core.settings - loadDefaultSettings(self) self.presetManager = PresetManager( uic.loadUi( os.path.join(Core.wd, 'presetmanager.ui')), self) @@ -92,13 +103,17 @@ class MainWindow(QtWidgets.QMainWindow): if not os.path.exists(neededDirectory): os.mkdir(neededDirectory) - # Make queues/timers for the preview thread + # Create the preview window and its thread, queues, and timers + self.previewWindow = PreviewWindow(self, os.path.join( + Core.wd, "background.png")) + window.verticalLayout_previewWrapper.addWidget(self.previewWindow) + self.previewQueue = Queue() self.previewThread = QtCore.QThread(self) self.previewWorker = preview_thread.Worker(self, self.previewQueue) + self.previewWorker.error.connect(self.previewWindow.threadError) self.previewWorker.moveToThread(self.previewThread) self.previewWorker.imageCreated.connect(self.showPreviewImage) - self.previewWorker.error.connect(self.cleanUp) self.previewThread.start() self.timer = QtCore.QTimer(self) @@ -106,6 +121,7 @@ class MainWindow(QtWidgets.QMainWindow): self.timer.start(500) # Begin decorating the window and connecting events + self.window.installEventFilter(self) componentList = self.window.listWidget_componentList if sys.platform == 'darwin': @@ -168,14 +184,9 @@ class MainWindow(QtWidgets.QMainWindow): window.spinBox_vBitrate.setValue(vBitrate) window.spinBox_aBitrate.setValue(aBitrate) - window.spinBox_vBitrate.valueChanged.connect(self.updateCodecSettings) window.spinBox_aBitrate.valueChanged.connect(self.updateCodecSettings) - self.previewWindow = PreviewWindow(self, os.path.join( - Core.wd, "background.png")) - window.verticalLayout_previewWrapper.addWidget(self.previewWindow) - # Make component buttons self.compMenu = QMenu() for i, comp in enumerate(self.core.modules): @@ -204,7 +215,7 @@ class MainWindow(QtWidgets.QMainWindow): currentRes = str(self.settings.value('outputWidth'))+'x' + \ str(self.settings.value('outputHeight')) - for i, res in enumerate(self.resolutions): + for i, res in enumerate(Core.resolutions): window.comboBox_resolution.addItem(res) if res == currentRes: currentRes = i @@ -375,6 +386,7 @@ class MainWindow(QtWidgets.QMainWindow): self.previewThread.quit() self.previewThread.wait() + @disableWhenOpeningProject def updateWindowTitle(self): appName = 'Audio Visualizer' try: @@ -442,13 +454,29 @@ class MainWindow(QtWidgets.QMainWindow): self.settings.setValue('outputVideoBitrate', currentVideoBitrate) self.settings.setValue('outputAudioBitrate', currentAudioBitrate) + @disableWhenOpeningProject def autosave(self, force=False): if not self.currentProject: if os.path.exists(self.autosavePath): os.remove(self.autosavePath) - elif force or time.time() - self.lastAutosave >= 0.2: + elif force or time.time() - self.lastAutosave >= self.autosaveCooldown: self.core.createProjectFile(self.autosavePath, self.window) self.lastAutosave = time.time() + if len(self.autosaveTimes) >= 5: + # Do some math to reduce autosave spam. This gives a smooth + # curve up to 5 seconds cooldown and maintains that for 30 secs + # if a component is continuously updated + timeDiff = self.lastAutosave - self.autosaveTimes.pop() + if not force and timeDiff >= 1.0 \ + and timeDiff <= 10.0: + if self.autosaveCooldown / 4.0 < 0.5: + self.autosaveCooldown += 1.0 + self.autosaveCooldown = ( + 5.0 * (self.autosaveCooldown / 5.0) + ) + (self.autosaveCooldown / 5.0) * 2 + elif force or timeDiff >= self.autosaveCooldown * 5: + self.autosaveCooldown = 0.2 + self.autosaveTimes.insert(0, self.lastAutosave) def autosaveExists(self, identical=True): '''Determines if creating the autosave should be blocked.''' @@ -602,15 +630,20 @@ class MainWindow(QtWidgets.QMainWindow): def updateResolution(self): resIndex = int(self.window.comboBox_resolution.currentIndex()) - res = self.resolutions[resIndex].split('x') + res = Core.resolutions[resIndex].split('x') self.settings.setValue('outputWidth', res[0]) self.settings.setValue('outputHeight', res[1]) self.drawPreview() - def drawPreview(self, force=False): + def drawPreview(self, force=False, **kwargs): + '''Use autosave keyword arg to force saving or not saving if needed''' self.newTask.emit(self.core.selectedComponents) # self.processTask.emit() - self.autosave(force) + if force or 'autosave' in kwargs: + if force or kwargs['autosave']: + self.autosave(True) + else: + self.autosave() self.updateWindowTitle() @QtCore.pyqtSlot(QtGui.QImage) @@ -685,9 +718,13 @@ class MainWindow(QtWidgets.QMainWindow): stackedWidget.insertWidget(newRow, page) componentList.setCurrentRow(newRow) stackedWidget.setCurrentIndex(newRow) - self.drawPreview() + self.drawPreview(True) - def getComponentListRects(self): + def getComponentListMousePos(self, position): + ''' + Given a QPos, returns the component index under the mouse cursor + or -1 if no component is there. + ''' componentList = self.window.listWidget_componentList modelIndexes = [ @@ -698,20 +735,23 @@ class MainWindow(QtWidgets.QMainWindow): componentList.visualRect(modelIndex) for modelIndex in modelIndexes ] - return rects + mousePos = [rect.contains(position) for rect in rects] + if not any(mousePos): + # Not clicking a component + mousePos = -1 + else: + mousePos = mousePos.index(True) + return mousePos @disableWhenEncoding def dragComponent(self, event): '''Used as Qt drop event for the component listwidget''' componentList = self.window.listWidget_componentList - rects = self.getComponentListRects() - - rowPos = [rect.contains(event.pos()) for rect in rects] - if not any(rowPos): - return - - i = rowPos.index(True) - change = (componentList.currentRow() - i) * -1 + mousePos = self.getComponentListMousePos(event.pos()) + if mousePos > -1: + change = (componentList.currentRow() - mousePos) * -1 + else: + change = (componentList.count() - componentList.currentRow() -1) self.moveComponent(change) def changeComponentWidget(self): @@ -814,9 +854,7 @@ class MainWindow(QtWidgets.QMainWindow): self.settings.setValue("projectDir", os.path.dirname(filepath)) # actually load the project using core method self.core.openProject(self, filepath) - if self.window.listWidget_componentList.count() == 0: - self.drawPreview() - self.autosave(True) + self.drawPreview(autosave=False) self.updateWindowTitle() def showMessage(self, **kwargs): @@ -843,20 +881,11 @@ class MainWindow(QtWidgets.QMainWindow): def componentContextMenu(self, QPos): '''Appears when right-clicking the component list''' componentList = self.window.listWidget_componentList - index = componentList.currentRow() - self.menu = QMenu() parentPosition = componentList.mapToGlobal(QtCore.QPoint(0, 0)) - rects = self.getComponentListRects() - rowPos = [rect.contains(QPos) for rect in rects] - if not any(rowPos): - # Insert components at the top if clicking nothing - rowPos = 0 - else: - rowPos = rowPos.index(True) - - if index == rowPos: + index = self.getComponentListMousePos(QPos) + if index > -1: # Show preset menu if clicking a component self.presetManager.findPresets() menuItem = self.menu.addAction("Save Preset") @@ -891,13 +920,23 @@ class MainWindow(QtWidgets.QMainWindow): # "Add Component" submenu self.submenu = QMenu("Add") self.menu.addMenu(self.submenu) + insertCompAtTop = self.settings.value("pref_insertCompAtTop") for i, comp in enumerate(self.core.modules): menuItem = self.submenu.addAction(comp.Component.name) menuItem.triggered.connect( lambda _, item=i: self.core.insertComponent( - rowPos, item, self + 0 if insertCompAtTop else index, item, self ) - ) + ) self.menu.move(parentPosition + QPos) self.menu.show() + + def eventFilter(self, object, event): + if event.type() == QtCore.QEvent.WindowActivate \ + or event.type() == QtCore.QEvent.FocusIn: + Core.windowHasFocus = True + elif event.type()== QtCore.QEvent.WindowDeactivate \ + or event.type() == QtCore.QEvent.FocusOut: + Core.windowHasFocus = False + return False diff --git a/src/mainwindow.ui b/src/mainwindow.ui index b491323..b43d375 100644 --- a/src/mainwindow.ui +++ b/src/mainwindow.ui @@ -22,6 +22,9 @@ 0 + + Qt::StrongFocus + MainWindow diff --git a/src/presetmanager.py b/src/presetmanager.py index 64e2203..643e180 100644 --- a/src/presetmanager.py +++ b/src/presetmanager.py @@ -6,7 +6,8 @@ from PyQt5 import QtCore, QtWidgets import string import os -import toolkit +from toolkit import badName +from core import Core class PresetManager(QtWidgets.QDialog): @@ -151,7 +152,7 @@ class PresetManager(QtWidgets.QDialog): currentPreset ) if OK: - if toolkit.badName(newName): + if badName(newName): self.warnMessage(self.parent.window) continue if newName: @@ -236,7 +237,6 @@ class PresetManager(QtWidgets.QDialog): os.remove(filepath) def warnMessage(self, window=None): - print(window) self.parent.showMessage( msg='Preset names must contain only letters, ' 'numbers, and spaces.', @@ -272,7 +272,7 @@ class PresetManager(QtWidgets.QDialog): self.presetRows[index][2] ) if OK: - if toolkit.badName(newName): + if badName(newName): self.warnMessage() continue if newName: @@ -289,7 +289,7 @@ class PresetManager(QtWidgets.QDialog): self.findPresets() self.drawPresetList() for i, comp in enumerate(self.core.selectedComponents): - if toolkit.getPresetDir(comp) == path \ + if getPresetDir(comp) == path \ and comp.currentPreset == oldName: self.core.openPreset(newPath, i, newName) self.parent.updateComponentTitle(i, False) @@ -338,3 +338,8 @@ class PresetManager(QtWidgets.QDialog): def clearPresetListSelection(self): self.window.listWidget_presets.setCurrentRow(-1) + + +def getPresetDir(comp): + '''Get the preset subdir for a particular version of a component''' + return os.path.join(Core.presetDir, str(comp), str(comp.version)) diff --git a/src/preview_thread.py b/src/preview_thread.py index 3fc73b3..9917e4b 100644 --- a/src/preview_thread.py +++ b/src/preview_thread.py @@ -10,12 +10,13 @@ from queue import Queue, Empty import os from toolkit.frame import Checkerboard +from toolkit import disableWhenOpeningProject class Worker(QtCore.QObject): imageCreated = pyqtSignal(QtGui.QImage) - error = pyqtSignal() + error = pyqtSignal(str) def __init__(self, parent=None, queue=None): QtCore.QObject.__init__(self) @@ -30,6 +31,7 @@ class Worker(QtCore.QObject): height = int(self.settings.value('outputHeight')) self.background = Checkerboard(width, height) + @disableWhenOpeningProject @pyqtSlot(list) def createPreviewImage(self, components): dic = { @@ -48,7 +50,6 @@ class Worker(QtCore.QObject): self.queue.get(block=False) except Empty: continue - if self.background.width != width \ or self.background.height != height: self.background = Checkerboard(width, height) @@ -65,20 +66,12 @@ class Worker(QtCore.QObject): except ValueError as e: errMsg = "Bad frame returned by %s's preview renderer. " \ - "%s. New frame size was %s*%s; should be %s*%s. " \ - "This is a fatal error." % ( + "%s. New frame size was %s*%s; should be %s*%s." % ( str(component), str(e).capitalize(), newFrame.width, newFrame.height, width, height ) - print(errMsg) - self.parent.showMessage( - msg=errMsg, - detail=str(e), - icon='Warning', - parent=None # MainWindow is in a different thread - ) - self.error.emit() + self.error.emit(errMsg) break except RuntimeError as e: print(e) diff --git a/src/toolkit/common.py b/src/toolkit/common.py index 763d582..5fe601f 100644 --- a/src/toolkit/common.py +++ b/src/toolkit/common.py @@ -8,13 +8,6 @@ import sys import subprocess from collections import OrderedDict -from toolkit.core import * - - -def getPresetDir(comp): - '''Get the preset subdirectory for a particular version of a component''' - return os.path.join(Core.presetDir, str(comp), str(comp.version)) - def badName(name): '''Returns whether a name contains non-alphanumeric chars''' @@ -66,14 +59,20 @@ def openPipe(commandList, **kwargs): def disableWhenEncoding(func): - ''' Blocks calls to a function while the video is being exported - in MainWindow. - ''' - def decorator(*args, **kwargs): - if args[0].encoding: + def decorator(self, *args, **kwargs): + if self.encoding: return else: - return func(*args, **kwargs) + return func(self, *args, **kwargs) + return decorator + + +def disableWhenOpeningProject(func): + def decorator(self, *args, **kwargs): + if self.core.openingProject: + return + else: + return func(self, *args, **kwargs) return decorator @@ -108,34 +107,3 @@ def rgbFromString(string): return tup except: return (255, 255, 255) - - -def loadDefaultSettings(self): - ''' - Runs once at each program start-up. Fills in default settings - for any settings not found in settings.ini - ''' - self.resolutions = [ - '1920x1080', - '1280x720', - '854x480' - ] - - default = { - "outputWidth": 1280, - "outputHeight": 720, - "outputFrameRate": 30, - "outputAudioCodec": "AAC", - "outputAudioBitrate": "192", - "outputVideoCodec": "H264", - "outputVideoBitrate": "2500", - "outputVideoFormat": "yuv420p", - "outputPreset": "medium", - "outputFormat": "mp4", - "outputContainer": "MP4", - "projectDir": os.path.join(self.dataDir, 'projects'), - } - - for parm, value in default.items(): - if self.settings.value(parm) is None: - self.settings.setValue(parm, value) diff --git a/src/toolkit/core.py b/src/toolkit/core.py deleted file mode 100644 index a96a684..0000000 --- a/src/toolkit/core.py +++ /dev/null @@ -1,18 +0,0 @@ -class Core: - '''A very complicated class for tracking settings''' - - -def init(settings): - global Core - for classvar, val in settings.items(): - setattr(Core, classvar, val) - - -def cancel(): - global Core - Core.canceled = True - - -def reset(): - global Core - Core.canceled = False diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py index cc59a6c..30dc0b3 100644 --- a/src/toolkit/ffmpeg.py +++ b/src/toolkit/ffmpeg.py @@ -4,18 +4,19 @@ import numpy import sys import os -import subprocess as sp +import subprocess -from toolkit.common import Core, checkOutput, openPipe +import core +from toolkit.common import checkOutput, openPipe def findFfmpeg(): if getattr(sys, 'frozen', False): # The application is frozen if sys.platform == "win32": - return os.path.join(Core.wd, 'ffmpeg.exe') + return os.path.join(core.Core.wd, 'ffmpeg.exe') else: - return os.path.join(Core.wd, 'ffmpeg') + return os.path.join(core.Core.wd, 'ffmpeg') else: if sys.platform == "win32": @@ -27,7 +28,7 @@ def findFfmpeg(): ['ffmpeg', '-version'], stderr=f ) return "ffmpeg" - except sp.CalledProcessError: + except subprocess.CalledProcessError: return "avconv" @@ -37,9 +38,9 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1): ''' if duration == -1: duration = getAudioDuration(inputFile) - safeDuration = "{0:.3f}".format(duration - 0.05) # used by filters duration = "{0:.3f}".format(duration + 0.1) # used by input sources + Core = core.Core # Test if user has libfdk_aac encoders = checkOutput( @@ -213,12 +214,28 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1): return ffmpegCommand +def testAudioStream(filename): + '''Test if an audio stream definitely exists''' + audioTestCommand = [ + core.Core.FFMPEG_BIN, + '-i', filename, + '-vn', '-f', 'null', '-' + ] + try: + checkOutput(audioTestCommand, stderr=subprocess.DEVNULL) + except subprocess.CalledProcessError: + return True + else: + return False + + def getAudioDuration(filename): - command = [Core.FFMPEG_BIN, '-i', filename] + '''Try to get duration of audio file as float, or False if not possible''' + command = [core.Core.FFMPEG_BIN, '-i', filename] try: - fileInfo = checkOutput(command, stderr=sp.STDOUT) - except sp.CalledProcessError as ex: + fileInfo = checkOutput(command, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as ex: fileInfo = ex.output info = fileInfo.decode("utf-8").split('\n') @@ -236,13 +253,17 @@ def getAudioDuration(filename): def readAudioFile(filename, parent): + ''' + Creates the completeAudioArray given to components + and used to draw the classic visualizer. + ''' duration = getAudioDuration(filename) if not duration: print('Audio file doesn\'t exist or unreadable.') return command = [ - Core.FFMPEG_BIN, + core.Core.FFMPEG_BIN, '-i', filename, '-f', 's16le', '-acodec', 'pcm_s16le', @@ -250,7 +271,8 @@ def readAudioFile(filename, parent): '-ac', '1', # mono (set to '2' for stereo) '-'] in_pipe = openPipe( - command, stdout=sp.PIPE, stderr=sp.DEVNULL, bufsize=10**8 + command, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=10**8 ) completeAudioArray = numpy.empty(0, dtype="int16") @@ -258,7 +280,7 @@ def readAudioFile(filename, parent): progress = 0 lastPercent = None while True: - if Core.canceled: + if core.Core.canceled: return # read 2 seconds of audio progress += 4 diff --git a/src/toolkit/frame.py b/src/toolkit/frame.py index 83fd59e..ca2a054 100644 --- a/src/toolkit/frame.py +++ b/src/toolkit/frame.py @@ -7,7 +7,7 @@ from PIL.ImageQt import ImageQt import sys import os -from toolkit.common import Core +import core class FramePainter(QtGui.QPainter): @@ -57,7 +57,7 @@ def Checkerboard(width, height): ''' image = FloodFrame(1920, 1080, (0, 0, 0, 0)) image.paste(Image.open( - os.path.join(Core.wd, "background.png")), + os.path.join(core.Core.wd, "background.png")), (0, 0) ) image = image.resize((width, height)) diff --git a/src/video_thread.py b/src/video_thread.py index 8517b92..7fe3e02 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -18,6 +18,7 @@ from threading import Thread, Event import time import signal +import core from toolkit import openPipe from toolkit.ffmpeg import readAudioFile, createFfmpegCommand from toolkit.frame import Checkerboard @@ -104,7 +105,8 @@ class Worker(QtCore.QObject): while not self.stopped: audioI, frame = self.previewQueue.get() - if time.time() - self.lastPreview >= 0.06 or audioI == 0: + if core.Core.windowHasFocus \ + and time.time() - self.lastPreview >= 0.06 or audioI == 0: image = Image.alpha_composite(background.copy(), frame) self.imageCreated.emit(QtGui.QImage(ImageQt(image))) self.lastPreview = time.time() @@ -231,7 +233,8 @@ class Worker(QtCore.QObject): self.lastPreview = 0.0 self.previewDispatch = Thread( - target=self.previewDispatch, name="Render Dispatch Thread") + target=self.previewDispatch, name="Render Dispatch Thread" + ) self.previewDispatch.daemon = True self.previewDispatch.start() -- cgit v1.2.3 From d38109453cea17a31c335837c0029ad51fa3dda1 Mon Sep 17 00:00:00 2001 From: tassaron Date: Sun, 23 Jul 2017 17:14:21 -0400 Subject: better component error messages fatal errors cancel the export instead of crashing --- src/component.py | 157 ++++++++++++++++++++++++++++++++++----------- src/components/original.py | 2 +- src/components/sound.py | 2 + src/components/video.py | 24 +++---- src/core.py | 10 ++- src/mainwindow.py | 15 ++++- src/toolkit/common.py | 8 +++ src/toolkit/ffmpeg.py | 2 +- src/video_thread.py | 52 ++++++++------- 9 files changed, 190 insertions(+), 82 deletions(-) (limited to 'src/components/sound.py') diff --git a/src/component.py b/src/component.py index bec2df5..8b5f1b8 100644 --- a/src/component.py +++ b/src/component.py @@ -5,13 +5,12 @@ from PyQt5 import uic, QtCore, QtWidgets import os -from presetmanager import getPresetDir - def commandWrapper(func): '''Intercepts each component's command() method to check for global args''' def decorator(self, arg): if arg.startswith('preset='): + from presetmanager import getPresetDir _, preset = arg.split('=', 1) path = os.path.join(getPresetDir(self), preset) if not os.path.exists(path): @@ -29,6 +28,26 @@ def commandWrapper(func): return decorator +def propertiesWrapper(func): + '''Intercepts the usual properties if the properties are locked.''' + def decorator(self): + if self._lockedProperties is not None: + return self._lockedProperties + else: + return func(self) + return decorator + + +def errorWrapper(func): + '''Intercepts the usual error message if it is locked.''' + def decorator(self): + if self._lockedError is not None: + return self._lockedError + else: + return func(self) + return decorator + + class ComponentMetaclass(type(QtCore.QObject)): ''' Checks the validity of each Component class imported, and @@ -37,25 +56,33 @@ class ComponentMetaclass(type(QtCore.QObject)): ''' def __new__(cls, name, parents, attrs): if 'ui' not in attrs: - # use module name as ui filename by default + # Use module name as ui filename by default attrs['ui'] = '%s.ui' % os.path.splitext( attrs['__module__'].split('.')[-1] )[0] - # Turn certain class methods into properties and classmethods - for key in ('error', 'properties', 'audio'): - if key not in attrs: - continue - attrs[key] = property(attrs[key]) + # if parents[0] == QtCore.QObject: else: + decorate = ('names', 'error', 'audio', 'command', 'properties') - for key in ('names'): + # Auto-decorate methods + for key in decorate: if key not in attrs: continue - attrs[key] = classmethod(key) - # Do not apply these mutations to the base class - if parents[0] != QtCore.QObject: - attrs['command'] = commandWrapper(attrs['command']) + if key in ('names'): + attrs[key] = classmethod(attrs[key]) + + if key in ('audio'): + attrs[key] = property(attrs[key]) + + if key == 'command': + attrs[key] = commandWrapper(attrs[key]) + + if key == 'properties': + attrs[key] = propertiesWrapper(attrs[key]) + + if key == 'error': + attrs[key] = errorWrapper(attrs[key]) # Turn version string into a number try: @@ -83,13 +110,13 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): name = 'Component' # ui = 'nameOfNonDefaultUiFile' + version = '1.0.0' # The major version (before the first dot) is used to determine # preset compatibility; the rest is ignored so it can be non-numeric. modified = QtCore.pyqtSignal(int, dict) - # ^ Signal used to tell core program that the component state changed, - # you shouldn't need to use this directly, it is used by self.update() + _error = QtCore.pyqtSignal(str, str) def __init__(self, moduleIndex, compPos, core): super().__init__() @@ -100,6 +127,9 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): self._trackedWidgets = {} self._presetNames = {} + self._commandArgs = {} + self._lockedProperties = None + self._lockedError = None # Stop lengthy processes in response to this variable self.canceled = False @@ -127,6 +157,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): def error(self): ''' Return a string containing an error message, or None for a default. + Or tuple of two strings for a message with details. ''' return @@ -141,12 +172,6 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): https://ffmpeg.org/ffmpeg-filters.html ''' - def names(): - ''' - Alternative names for renaming a component between project files. - ''' - return [] - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ # Methods # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ @@ -181,15 +206,29 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): for widget in widgets['comboBox']: widget.currentIndexChanged.connect(self.update) - def trackWidgets(self, trackDict, presetNames=None): + def trackWidgets(self, trackDict, **kwargs): ''' - Name widgets to track in update(), savePreset(), and loadPreset() - Accepts a dict with attribute names as keys and widgets as values. - Optional: a dict of attribute names to map to preset variable names + Name widgets to track in update(), savePreset(), loadPreset(), and + command(). Requires a dict of attr names as keys, widgets as values + + Optional args: + 'presetNames': preset variable names to replace attr names + 'commandArgs': arg keywords that differ from attr names + + NOTE: Any kwarg key set to None will selectively disable tracking. ''' self._trackedWidgets = trackDict - if type(presetNames) is dict: - self._presetNames = presetNames + for kwarg in kwargs: + try: + if kwarg in ('presetNames', 'commandArgs'): + setattr(self, '_%s' % kwarg, kwargs[kwarg]) + else: + raise BadComponentInit( + self, + 'Nonsensical keywords to trackWidgets.', + immediate=True) + except BadComponentInit: + continue def update(self): ''' @@ -277,6 +316,22 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): self.commandHelp() quit(0) + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # "Private" Methods + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + + def lockProperties(self, propList): + self._lockedProperties = propList + + def lockError(self, msg): + self._lockedError = msg + + def unlockProperties(self): + self._lockedProperties = None + + def unlockError(self): + self._lockedError = None + def loadUi(self, filename): '''Load a Qt Designer ui file to use for this component's widget''' return uic.loadUi(os.path.join(self.core.componentsPath, filename)) @@ -287,6 +342,8 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): def reset(self): self.canceled = False + self.unlockProperties() + self.unlockError() ''' ### Reference methods for creating a new component @@ -309,16 +366,40 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): ''' -class BadComponentInit(Exception): +class BadComponentInit(AttributeError): ''' - General purpose exception that components can raise to indicate - a Python issue with e.g., dynamic creation of instances or something. - Decorative for now, may have future use for logging. + Indicates a Python error in constructing a component. + Raising this locks the component into an error state, + and gives the MainWindow a traceback to display. ''' - def __init__(self, arg, name): - string = '''################################ -Mandatory argument "%s" not specified - in %s instance initialization -###################################''' - print(string % (arg, name)) - quit() + def __init__(self, caller, name, immediate=False): + from toolkit import formatTraceback + import sys + if sys.exc_info()[0] is not None: + string = ( + "%s component's %s encountered %s %s." % ( + caller.__class__.name, + name, + 'an' if any([ + sys.exc_info()[0].__name__.startswith(vowel) + for vowel in ('A', 'I') + ]) else 'a', + sys.exc_info()[0].__name__, + ) + ) + detail = formatTraceback(sys.exc_info()[2]) + else: + string = name + detail = "Methods:\n%s" % ( + "\n".join( + [m for m in dir(caller) if not m.startswith('_')] + ) + ) + + if immediate: + caller.parent.showMessage( + msg=string, detail=detail, icon='Warning' + ) + else: + caller.lockProperties(['error']) + caller.lockError((string, detail)) diff --git a/src/components/original.py b/src/components/original.py index 2bda878..570465d 100644 --- a/src/components/original.py +++ b/src/components/original.py @@ -15,7 +15,7 @@ class Component(Component): name = 'Classic Visualizer' version = '1.0.0' - def names(): + def names(*args): return ['Original Audio Visualization'] def widget(self, *args): diff --git a/src/components/sound.py b/src/components/sound.py index dd3cbab..b3a627a 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -18,6 +18,8 @@ class Component(Component): 'chorus': self.page.checkBox_chorus, 'delay': self.page.spinBox_delay, 'volume': self.page.spinBox_volume, + }, commandArgs={ + 'sound': None, }) def previewRender(self, previewWorker): diff --git a/src/components/video.py b/src/components/video.py index 677e3ee..d3696d4 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -14,7 +14,7 @@ from toolkit import openPipe, checkOutput class Video: - '''Video Component Frame-Fetcher''' + '''Opens a pipe to ffmpeg and stores a buffer of raw video frames.''' def __init__(self, **kwargs): mandatoryArgs = [ 'ffmpeg', # path to ffmpeg, usually self.core.FFMPEG_BIN @@ -28,10 +28,7 @@ class Video: 'component', # component object ] for arg in mandatoryArgs: - try: - setattr(self, arg, kwargs[arg]) - except KeyError: - raise BadComponentInit(arg, self.__doc__) + setattr(self, arg, kwargs[arg]) self.frameNo = -1 self.currentFrame = 'None' @@ -196,13 +193,16 @@ class Component(Component): height = int(self.settings.value('outputHeight')) self.blankFrame_ = BlankFrame(width, height) self.updateChunksize(width, height) - self.video = Video( - ffmpeg=self.core.FFMPEG_BIN, videoPath=self.videoPath, - width=width, height=height, chunkSize=self.chunkSize, - frameRate=int(self.settings.value("outputFrameRate")), - parent=self.parent, loopVideo=self.loopVideo, - component=self, scale=self.scale - ) if os.path.exists(self.videoPath) else None + try: + self.video = Video( + ffmpeg=self.core.FFMPEG_BIN, #videoPath=self.videoPath, + width=width, height=height, chunkSize=self.chunkSize, + frameRate=int(self.settings.value("outputFrameRate")), + parent=self.parent, loopVideo=self.loopVideo, + component=self, scale=self.scale + ) if os.path.exists(self.videoPath) else None + except KeyError: + raise BadComponentInit(self, 'Frame Fetcher initialization') def frameRender(self, layerNo, frameNo): if self.video: diff --git a/src/core.py b/src/core.py index eb6398b..2f9c36c 100644 --- a/src/core.py +++ b/src/core.py @@ -22,13 +22,12 @@ class Core: ''' def __init__(self): - self.findComponents() + self.importComponents() self.selectedComponents = [] self.savedPresets = {} # copies of presets to detect modification self.openingProject = False - def findComponents(self): - '''Imports all the component modules''' + def importComponents(self): def findComponents(): for f in os.listdir(Core.componentsPath): name, ext = os.path.splitext(f) @@ -225,9 +224,8 @@ class Core: return if hasattr(loader, 'createNewProject'): loader.createNewProject(prompt=False) - import traceback - msg = '%s: %s\n\nTraceback:\n' % (typ.__name__, value) - msg += "\n".join(traceback.format_tb(tb)) + msg = '%s: %s\n\n' % (typ.__name__, value) + msg += toolkit.formatTraceback(tb) loader.showMessage( msg="Project file '%s' is corrupted." % filepath, showCancel=False, diff --git a/src/mainwindow.py b/src/mainwindow.py index f333513..a32c1b4 100644 --- a/src/mainwindow.py +++ b/src/mainwindow.py @@ -571,6 +571,15 @@ class MainWindow(QtWidgets.QMainWindow): self.videoWorker.encoding.connect(self.changeEncodingStatus) self.createVideo.emit() + @QtCore.pyqtSlot(str, str) + def videoThreadError(self, msg, detail): + self.showMessage( + msg=msg, + detail=detail, + icon='Warning', + ) + self.stopVideo() + def changeEncodingStatus(self, status): self.encoding = status if status: @@ -675,6 +684,8 @@ class MainWindow(QtWidgets.QMainWindow): # connect to signal that adds an asterisk when modified self.core.selectedComponents[index].modified.connect( self.updateComponentTitle) + self.core.selectedComponents[index]._error.connect( + self.videoThreadError) self.pages.insert(index, self.core.selectedComponents[index].page) stackedWidget.insertWidget(index, self.pages[index]) @@ -751,7 +762,7 @@ class MainWindow(QtWidgets.QMainWindow): if mousePos > -1: change = (componentList.currentRow() - mousePos) * -1 else: - change = (componentList.count() - componentList.currentRow() -1) + change = (componentList.count() - componentList.currentRow() - 1) self.moveComponent(change) def changeComponentWidget(self): @@ -936,7 +947,7 @@ class MainWindow(QtWidgets.QMainWindow): if event.type() == QtCore.QEvent.WindowActivate \ or event.type() == QtCore.QEvent.FocusIn: Core.windowHasFocus = True - elif event.type()== QtCore.QEvent.WindowDeactivate \ + elif event.type() == QtCore.QEvent.WindowDeactivate \ or event.type() == QtCore.QEvent.FocusOut: Core.windowHasFocus = False return False diff --git a/src/toolkit/common.py b/src/toolkit/common.py index 5fe601f..251a2c1 100644 --- a/src/toolkit/common.py +++ b/src/toolkit/common.py @@ -107,3 +107,11 @@ def rgbFromString(string): return tup except: return (255, 255, 255) + + +def formatTraceback(tb=None): + import traceback + if tb is None: + import sys + tb = sys.exc_info()[2] + return 'Traceback:\n%s' % "\n".join(traceback.format_tb(tb)) diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py index 30dc0b3..8f5ae87 100644 --- a/src/toolkit/ffmpeg.py +++ b/src/toolkit/ffmpeg.py @@ -103,7 +103,7 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1): globalFilters = 0 # increase to add global filters extraAudio = [ comp.audio for comp in components - if 'audio' in comp.properties + if 'audio' in comp.properties() ] if extraAudio or globalFilters > 0: # Add -i options for extra input files diff --git a/src/video_thread.py b/src/video_thread.py index 7fe3e02..68eae4f 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -18,7 +18,7 @@ from threading import Thread, Event import time import signal -import core +from component import BadComponentInit from toolkit import openPipe from toolkit.ffmpeg import readAudioFile, createFfmpegCommand from toolkit.frame import Checkerboard @@ -105,8 +105,7 @@ class Worker(QtCore.QObject): while not self.stopped: audioI, frame = self.previewQueue.get() - if core.Core.windowHasFocus \ - and time.time() - self.lastPreview >= 0.06 or audioI == 0: + if time.time() - self.lastPreview >= 0.06 or audioI == 0: image = Image.alpha_composite(background.copy(), frame) self.imageCreated.emit(QtGui.QImage(ImageQt(image))) self.lastPreview = time.time() @@ -153,39 +152,48 @@ class Worker(QtCore.QObject): ])) self.staticComponents = {} for compNo, comp in enumerate(reversed(self.components)): - comp.preFrameRender( - worker=self, - completeAudioArray=self.completeAudioArray, - sampleSize=self.sampleSize, - progressBarUpdate=self.progressBarUpdate, - progressBarSetText=self.progressBarSetText - ) + try: + comp.preFrameRender( + worker=self, + completeAudioArray=self.completeAudioArray, + sampleSize=self.sampleSize, + progressBarUpdate=self.progressBarUpdate, + progressBarSetText=self.progressBarSetText + ) + except BadComponentInit: + pass - if 'error' in comp.properties: + if 'error' in comp.properties(): self.cancel() self.canceled = True canceledByComponent = True - errMsg = "Component #%s encountered an error!" % compNo \ - if comp.error is None else 'Component #%s (%s): %s' % ( + compError = comp.error() \ + if type(comp.error()) is tuple else (comp.error(), '') + errMsg = ( + "Component #%s encountered an error!" % compNo + if comp.error() is None else + 'Export cancelled by component #%s (%s): %s' % ( str(compNo), str(comp), - comp.error - ) - self.parent.showMessage( - msg=errMsg, - icon='Warning', - parent=None # MainWindow is in a different thread + compError[0] ) + ) + comp._error.emit(errMsg, compError[1]) break - if 'static' in comp.properties: + if 'static' in comp.properties(): self.staticComponents[compNo] = \ comp.frameRender(compNo, 0).copy() if self.canceled: if canceledByComponent: print('Export cancelled by component #%s (%s): %s' % ( - compNo, str(comp), comp.error - )) + compNo, + comp.name, + 'No message.' if comp.error() is None else ( + comp.error() if type(comp.error()) is str + else comp.error()[0]) + ) + ) self.cancelExport() return -- cgit v1.2.3 From 661526b0739115594fda4c0e876398cdc940fbe1 Mon Sep 17 00:00:00 2001 From: tassaron Date: Tue, 25 Jul 2017 17:44:59 -0400 Subject: repeated errors don't cause repeated windows --- src/component.py | 15 ++++++++++-- src/components/sound.py | 1 - src/components/video.py | 4 ++-- src/core.py | 15 ++++++------ src/mainwindow.py | 4 ++-- src/presetmanager.py | 61 +++++++++++++++++++++++++++---------------------- src/video_thread.py | 8 +++---- 7 files changed, 63 insertions(+), 45 deletions(-) (limited to 'src/components/sound.py') diff --git a/src/component.py b/src/component.py index 48e9c1a..7a768ed 100644 --- a/src/component.py +++ b/src/component.py @@ -17,7 +17,7 @@ class ComponentMetaclass(type(QtCore.QObject)): def initializationWrapper(self, *args, **kwargs): try: return func(self, *args, **kwargs) - except: + except Exception: try: raise ComponentInitError(self, 'initialization process') except ComponentError: @@ -28,7 +28,7 @@ class ComponentMetaclass(type(QtCore.QObject)): def renderWrapper(self, *args, **kwargs): try: return func(self, *args, **kwargs) - except: + except Exception: from toolkit.frame import BlankFrame try: raise ComponentError(self, 'renderer') @@ -398,8 +398,19 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): class ComponentException(RuntimeError): '''A base class for component errors''' + + _prevErrors = [] + def __init__(self, caller, name, immediate): + print('ComponentError by %s: %s' % (caller.name, name)) super().__init__() + if len(ComponentException._prevErrors) > 1: + ComponentException._prevErrors.pop() + ComponentException._prevErrors.insert(0, name) + if name in ComponentException._prevErrors[1:]: + # Don't create multiple windows for repeated messages + return + from toolkit import formatTraceback import sys if sys.exc_info()[0] is not None: diff --git a/src/components/sound.py b/src/components/sound.py index b3a627a..fcd9e4e 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -1,7 +1,6 @@ from PyQt5 import QtGui, QtCore, QtWidgets import os -from core import Core from component import Component from toolkit.frame import BlankFrame diff --git a/src/components/video.py b/src/components/video.py index 153fc4d..6b0a04a 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -6,8 +6,7 @@ import subprocess import threading from queue import PriorityQueue -from core import Core -from component import Component, ComponentError +from component import Component from toolkit.frame import BlankFrame from toolkit.ffmpeg import testAudioStream from toolkit import openPipe, checkOutput @@ -155,6 +154,7 @@ class Component(Component): return frame def properties(self): + # TODO: Disallow selecting the same video you're exporting to props = [] if not self.videoPath or self.badVideo \ or not os.path.exists(self.videoPath): diff --git a/src/core.py b/src/core.py index 4c08c04..b371d64 100644 --- a/src/core.py +++ b/src/core.py @@ -215,7 +215,7 @@ class Core: if hasattr(loader, 'updateComponentTitle'): loader.updateComponentTitle(i, modified) - except: + except Exception: errcode = 1 data = sys.exc_info() @@ -237,9 +237,10 @@ class Core: self.openingProject = False def parseAvFile(self, filepath): - '''Parses an avp (project) or avl (preset package) file. - Returns dictionary with section names as the keys, each one - contains a list of tuples: (compName, version, compPresetDict) + ''' + Parses an avp (project) or avl (preset package) file. + Returns dictionary with section names as the keys, each one + contains a list of tuples: (compName, version, compPresetDict) ''' validSections = ( 'Components', @@ -287,7 +288,7 @@ class Core: data[section].append((key, value.strip())) return 0, data - except: + except Exception: return 1, sys.exc_info() def importPreset(self, filepath): @@ -332,7 +333,7 @@ class Core: exportPath ) return True - except: + except Exception: return False def createPresetFile( @@ -397,7 +398,7 @@ class Core: ) ) return True - except: + except Exception: return False def newVideoWorker(self, loader, audioFile, outputPath): diff --git a/src/mainwindow.py b/src/mainwindow.py index 03b8dde..3cc5d26 100644 --- a/src/mainwindow.py +++ b/src/mainwindow.py @@ -314,7 +314,7 @@ class MainWindow(QtWidgets.QMainWindow): ['ffmpeg', '-version'], stderr=f ) goodVersion = str(ffmpegVers).split()[2].startswith('3') - except: + except Exception: goodVersion = False else: goodVersion = True @@ -381,7 +381,7 @@ class MainWindow(QtWidgets.QMainWindow): ) @QtCore.pyqtSlot() - def cleanUp(self): + def cleanUp(self, *args): self.timer.stop() self.previewThread.quit() self.previewThread.wait() diff --git a/src/presetmanager.py b/src/presetmanager.py index e602c16..b1eeb34 100644 --- a/src/presetmanager.py +++ b/src/presetmanager.py @@ -211,10 +211,9 @@ class PresetManager(QtWidgets.QDialog): self.parent.drawPreview() def openDeletePresetDialog(self): - selected = self.window.listWidget_presets.selectedItems() - if not selected: + row = self.getPresetRow() + if row == -1: return - row = self.window.listWidget_presets.row(selected[0]) comp, vers, name = self.presetRows[row] ch = self.parent.showMessage( msg='Really delete %s?' % name, @@ -242,32 +241,40 @@ class PresetManager(QtWidgets.QDialog): 'numbers, and spaces.', parent=window if window else self.window) + def getPresetRow(self): + row = self.window.listWidget_presets.currentRow() + if row > -1: + return row + + # check if component selected in MainWindow has preset loaded + componentList = self.parent.window.listWidget_componentList + compIndex = componentList.currentRow() + if compIndex == -1: + return compIndex + + preset = self.core.selectedComponents[compIndex].currentPreset + if preset is None: + return -1 + else: + rowTuple = ( + self.core.selectedComponents[compIndex].name, + self.core.selectedComponents[compIndex].version, + preset + ) + for i, tup in enumerate(self.presetRows): + if rowTuple == tup: + index = i + break + else: + return -1 + return index + def openRenamePresetDialog(self): # TODO: maintain consistency by changing this to call createNewPreset() presetList = self.window.listWidget_presets - index = presetList.currentRow() + index = self.getPresetRow() if index == -1: - # check if component selected in MainWindow has preset loaded - componentList = self.parent.window.listWidget_componentList - compIndex = componentList.currentRow() - if compIndex == -1: - return - - preset = self.core.selectedComponents[compIndex].currentPreset - if preset is None: - return - else: - rowTuple = ( - self.core.selectedComponents[compIndex].name, - self.core.selectedComponents[compIndex].version, - preset - ) - for i, tup in enumerate(self.presetRows): - if rowTuple == tup: - index = i - break - else: - return + return while True: newName, OK = QtWidgets.QInputDialog.getText( @@ -326,14 +333,14 @@ class PresetManager(QtWidgets.QDialog): self.settings.setValue("presetDir", os.path.dirname(filename)) def openExportDialog(self): - if not self.window.listWidget_presets.selectedItems(): + index = self.getPresetRow() + if index == -1: return filename, _ = QtWidgets.QFileDialog.getSaveFileName( self.window, "Export Preset", self.settings.value("presetDir"), "Preset Files (*.avl)") if filename: - index = self.window.listWidget_presets.currentRow() comp, vers, name = self.presetRows[index] if not self.core.exportPreset(filename, comp, vers, name): self.parent.showMessage( diff --git a/src/video_thread.py b/src/video_thread.py index dd957e5..8cbe8a8 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -225,7 +225,7 @@ class Worker(QtCore.QObject): self.renderThreads = [] try: numCpus = len(os.sched_getaffinity(0)) - except: + except Exception: numCpus = os.cpu_count() for i in range(2 if numCpus <= 2 else 3): @@ -268,7 +268,7 @@ class Worker(QtCore.QObject): try: self.out_pipe.stdin.write(frameBuffer[audioI].tobytes()) self.previewQueue.put([audioI, frameBuffer.pop(audioI)]) - except: + except Exception: break # increase progress bar value @@ -293,7 +293,7 @@ class Worker(QtCore.QObject): print("Export Canceled") try: os.remove(self.outputFile) - except: + except Exception: pass self.progressBarUpdate.emit(0) self.progressBarSetText.emit('Export Canceled') @@ -333,7 +333,7 @@ class Worker(QtCore.QObject): try: self.out_pipe.send_signal(signal.SIGINT) - except: + except Exception: pass def reset(self): -- cgit v1.2.3 From de1324a6a75eb2a9f97d8a6b416077cfc73b2bc9 Mon Sep 17 00:00:00 2001 From: tassaron Date: Thu, 27 Jul 2017 17:49:08 -0400 Subject: fixed video component eating stdout + made height/width into properties to simplify render methods --- src/component.py | 150 +++++++++++++++++++++++++-------------------- src/components/color.py | 12 ++-- src/components/image.py | 12 ++-- src/components/original.py | 10 +-- src/components/sound.py | 10 --- src/components/text.py | 13 ++-- src/components/video.py | 67 ++++++++++---------- src/preview_thread.py | 2 +- src/toolkit/ffmpeg.py | 6 +- src/video_thread.py | 14 +++-- 10 files changed, 141 insertions(+), 155 deletions(-) (limited to 'src/components/sound.py') diff --git a/src/component.py b/src/component.py index 5de67d1..1c5ccb3 100644 --- a/src/component.py +++ b/src/component.py @@ -4,6 +4,9 @@ ''' from PyQt5 import uic, QtCore, QtWidgets import os +import time + +from toolkit.frame import BlankFrame class ComponentMetaclass(type(QtCore.QObject)): @@ -28,10 +31,12 @@ class ComponentMetaclass(type(QtCore.QObject)): def renderWrapper(self, *args, **kwargs): try: return func(self, *args, **kwargs) - except Exception: - from toolkit.frame import BlankFrame + except Exception as e: try: - raise ComponentError(self, 'renderer') + if e.__name__.startswith('Component'): + raise + else: + raise ComponentError(self, 'renderer') except ComponentError: return BlankFrame() return renderWrapper @@ -93,7 +98,7 @@ class ComponentMetaclass(type(QtCore.QObject)): 'names', # Class methods 'error', 'audio', 'properties', # Properties 'preFrameRender', 'previewRender', - 'command', + 'frameRender', 'command', ) # Auto-decorate methods @@ -110,7 +115,7 @@ class ComponentMetaclass(type(QtCore.QObject)): if key == 'command': attrs[key] = cls.commandWrapper(attrs[key]) - if key == 'previewRender': + if key in ('previewRender', 'frameRender'): attrs[key] = cls.renderWrapper(attrs[key]) if key == 'preFrameRender': @@ -180,6 +185,37 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): self.__class__.name, str(self.__class__.version), self.savePreset() ) + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # Critical Methods + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + + def previewRender(self): + image = BlankFrame(self.width, self.height) + return image + + def preFrameRender(self, **kwargs): + ''' + Must call super() when subclassing + Triggered only before a video is exported (video_thread.py) + self.worker = the video thread worker + self.completeAudioArray = a list of audio samples + self.sampleSize = number of audio samples per video frame + self.progressBarUpdate = signal to set progress bar number + self.progressBarSetText = signal to set progress bar text + Use the latter two signals to update the MainWindow if needed + for a long initialization procedure (i.e., for a visualizer) + ''' + for key, value in kwargs.items(): + setattr(self, key, value) + + def frameRender(self, frameNo): + audioArrayIndex = frameNo * self.sampleSize + image = BlankFrame(self.width, self.height) + return image + + def renderFinished(self): + pass + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ # Properties # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ @@ -196,6 +232,8 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): ''' Return a string containing an error message, or None for a default. Or tuple of two strings for a message with details. + Alternatively use lockError(msgString) within properties() + to skip this method entirely. ''' return @@ -211,7 +249,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): ''' # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - # Methods + # Idle Methods # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ def widget(self, parent): @@ -244,33 +282,11 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): for widget in widgets['comboBox']: widget.currentIndexChanged.connect(self.update) - def trackWidgets(self, trackDict, **kwargs): - ''' - Name widgets to track in update(), savePreset(), loadPreset(), and - command(). Requires a dict of attr names as keys, widgets as values - - Optional args: - 'presetNames': preset variable names to replace attr names - 'commandArgs': arg keywords that differ from attr names - - NOTE: Any kwarg key set to None will selectively disable tracking. - ''' - self._trackedWidgets = trackDict - for kwarg in kwargs: - try: - if kwarg in ('presetNames', 'commandArgs'): - setattr(self, '_%s' % kwarg, kwargs[kwarg]) - else: - raise ComponentError( - self, 'Nonsensical keywords to trackWidgets.') - except ComponentError: - continue - def update(self): ''' Reads all tracked widget values into instance attributes and tells the MainWindow that the component was modified. - Call at the END of your method if you need to subclass this. + Call super() at the END if you need to subclass this. ''' for attr, widget in self._trackedWidgets.items(): if type(widget) == QtWidgets.QLineEdit: @@ -320,20 +336,6 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): ] = getattr(self, attr) return saveValueStore - def preFrameRender(self, **kwargs): - ''' - Triggered only before a video is exported (video_thread.py) - self.worker = the video thread worker - self.completeAudioArray = a list of audio samples - self.sampleSize = number of audio samples per video frame - self.progressBarUpdate = signal to set progress bar number - self.progressBarSetText = signal to set progress bar text - Use the latter two signals to update the MainWindow if needed - for a long initialization procedure (i.e., for a visualizer) - ''' - for key, value in kwargs.items(): - setattr(self, key, value) - def commandHelp(self): '''Help text as string for this component's commandline arguments''' @@ -356,6 +358,28 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): # "Private" Methods # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + def trackWidgets(self, trackDict, **kwargs): + ''' + Name widgets to track in update(), savePreset(), loadPreset(), and + command(). Requires a dict of attr names as keys, widgets as values + + Optional args: + 'presetNames': preset variable names to replace attr names + 'commandArgs': arg keywords that differ from attr names + + NOTE: Any kwarg key set to None will selectively disable tracking. + ''' + self._trackedWidgets = trackDict + for kwarg in kwargs: + try: + if kwarg in ('presetNames', 'commandArgs'): + setattr(self, '_%s' % kwarg, kwargs[kwarg]) + else: + raise ComponentError( + self, 'Nonsensical keywords to trackWidgets.') + except ComponentError: + continue + def lockProperties(self, propList): self._lockedProperties = propList @@ -372,6 +396,14 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): '''Load a Qt Designer ui file to use for this component's widget''' return uic.loadUi(os.path.join(self.core.componentsPath, filename)) + @property + def width(self): + return int(self.settings.value('outputWidth')) + + @property + def height(self): + return int(self.settings.value('outputHeight')) + def cancel(self): '''Stop any lengthy process in response to this variable.''' self.canceled = True @@ -381,41 +413,24 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): self.unlockProperties() self.unlockError() - ''' - ### Reference methods for creating a new component - ### (Inherit from this class and define these) - - def previewRender(self, previewWorker): - width = int(self.settings.value('outputWidth')) - height = int(self.settings.value('outputHeight')) - from toolkit.frame import BlankFrame - image = BlankFrame(width, height) - return image - - def frameRender(self, layerNo, frameNo): - audioArrayIndex = frameNo * self.sampleSize - width = int(self.settings.value('outputWidth')) - height = int(self.settings.value('outputHeight')) - from toolkit.frame import BlankFrame - image = BlankFrame(width, height) - return image - ''' - class ComponentError(RuntimeError): '''Gives the MainWindow a traceback to display, and cancels the export.''' prevErrors = [] + lastTime = time.time() def __init__(self, caller, name): - print('ComponentError by %s: %s' % (caller.name, name)) - super().__init__() + print('##### ComponentError by %s: %s' % (caller.name, name)) if len(ComponentError.prevErrors) > 1: ComponentError.prevErrors.pop() ComponentError.prevErrors.insert(0, name) - if name in ComponentError.prevErrors[1:]: - # Don't create multiple windows for repeated messages + curTime = time.time() + if name in ComponentError.prevErrors[1:] \ + and curTime - ComponentError.lastTime < 0.2: + # Don't create multiple windows for quickly repeated messages return + ComponentError.lastTime = time.time() from toolkit import formatTraceback import sys @@ -440,4 +455,5 @@ class ComponentError(RuntimeError): ) ) + super().__init__(string) caller._error.emit(string, detail) diff --git a/src/components/color.py b/src/components/color.py index 8257ed9..2abd79a 100644 --- a/src/components/color.py +++ b/src/components/color.py @@ -96,18 +96,14 @@ class Component(Component): super().update() - def previewRender(self, previewWorker): - width = int(self.settings.value('outputWidth')) - height = int(self.settings.value('outputHeight')) - return self.drawFrame(width, height) + def previewRender(self): + return self.drawFrame(self.width, self.height) def properties(self): return ['static'] - def frameRender(self, layerNo, frameNo): - width = int(self.settings.value('outputWidth')) - height = int(self.settings.value('outputHeight')) - return self.drawFrame(width, height) + def frameRender(self, frameNo): + return self.drawFrame(self.width, self.height) def drawFrame(self, width, height): r, g, b = self.color1 diff --git a/src/components/image.py b/src/components/image.py index a705904..a96f127 100644 --- a/src/components/image.py +++ b/src/components/image.py @@ -31,10 +31,8 @@ class Component(Component): }, ) - def previewRender(self, previewWorker): - width = int(self.settings.value('outputWidth')) - height = int(self.settings.value('outputHeight')) - return self.drawFrame(width, height) + def previewRender(self): + return self.drawFrame(self.width, self.height) def properties(self): props = ['static'] @@ -48,10 +46,8 @@ class Component(Component): if not os.path.exists(self.imagePath): return "The image selected does not exist!" - def frameRender(self, layerNo, frameNo): - width = int(self.settings.value('outputWidth')) - height = int(self.settings.value('outputHeight')) - return self.drawFrame(width, height) + def frameRender(self, frameNo): + return self.drawFrame(self.width, self.height) def drawFrame(self, width, height): frame = BlankFrame(width, height) diff --git a/src/components/original.py b/src/components/original.py index 570465d..3d1a574 100644 --- a/src/components/original.py +++ b/src/components/original.py @@ -59,13 +59,11 @@ class Component(Component): saveValueStore['visColor'] = self.visColor return saveValueStore - def previewRender(self, previewWorker): + def previewRender(self): spectrum = numpy.fromfunction( lambda x: float(self.scale)/2500*(x-128)**2, (255,), dtype="int16") - width = int(self.settings.value('outputWidth')) - height = int(self.settings.value('outputHeight')) return self.drawBars( - width, height, spectrum, self.visColor, self.layout + self.width, self.height, spectrum, self.visColor, self.layout ) def preFrameRender(self, **kwargs): @@ -74,8 +72,6 @@ class Component(Component): self.smoothConstantUp = 0.8 self.lastSpectrum = None self.spectrumArray = {} - self.width = int(self.settings.value('outputWidth')) - self.height = int(self.settings.value('outputHeight')) for i in range(0, len(self.completeAudioArray), self.sampleSize): if self.canceled: @@ -93,7 +89,7 @@ class Component(Component): self.progressBarSetText.emit(pStr) self.progressBarUpdate.emit(int(progress)) - def frameRender(self, layerNo, frameNo): + def frameRender(self, frameNo): arrayNo = frameNo * self.sampleSize return self.drawBars( self.width, self.height, diff --git a/src/components/sound.py b/src/components/sound.py index fcd9e4e..aff43d3 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -21,11 +21,6 @@ class Component(Component): 'sound': None, }) - def previewRender(self, previewWorker): - width = int(self.settings.value('outputWidth')) - height = int(self.settings.value('outputHeight')) - return BlankFrame(width, height) - def preFrameRender(self, **kwargs): pass @@ -63,11 +58,6 @@ class Component(Component): self.page.lineEdit_sound.setText(filename) self.update() - def frameRender(self, layerNo, frameNo): - width = int(self.settings.value('outputWidth')) - height = int(self.settings.value('outputHeight')) - return BlankFrame(width, height) - def commandHelp(self): print('Path to audio file:\n path=/filepath/to/sound.ogg') diff --git a/src/components/text.py b/src/components/text.py index 1d64617..8a302ff 100644 --- a/src/components/text.py +++ b/src/components/text.py @@ -97,10 +97,8 @@ class Component(Component): saveValueStore['textColor'] = self.textColor return saveValueStore - def previewRender(self, previewWorker): - width = int(self.settings.value('outputWidth')) - height = int(self.settings.value('outputHeight')) - return self.addText(width, height) + def previewRender(self): + return self.addText(self.width, self.height) def properties(self): props = ['static'] @@ -111,13 +109,10 @@ class Component(Component): def error(self): return "No text provided." - def frameRender(self, layerNo, frameNo): - width = int(self.settings.value('outputWidth')) - height = int(self.settings.value('outputHeight')) - return self.addText(width, height) + def frameRender(self, frameNo): + return self.addText(self.width, self.height) def addText(self, width, height): - image = FramePainter(width, height) self.titleFont.setPixelSize(self.fontSize) image.setFont(self.titleFont) diff --git a/src/components/video.py b/src/components/video.py index 8872fbf..48ac557 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -3,10 +3,11 @@ from PyQt5 import QtGui, QtCore, QtWidgets import os import math import subprocess +import signal import threading from queue import PriorityQueue -from component import Component +from component import Component, ComponentError from toolkit.frame import BlankFrame from toolkit.ffmpeg import testAudioStream from toolkit import openPipe, checkOutput @@ -14,6 +15,10 @@ from toolkit import openPipe, checkOutput class Video: '''Opens a pipe to ffmpeg and stores a buffer of raw video frames.''' + + # error from the thread used to fill the buffer + threadError = None + def __init__(self, **kwargs): mandatoryArgs = [ 'ffmpeg', # path to ffmpeg, usually self.core.FFMPEG_BIN @@ -71,8 +76,8 @@ class Video: self.frameBuffer.task_done() def fillBuffer(self): - pipe = openPipe( - self.command, stdout=subprocess.PIPE, + self.pipe = openPipe( + self.command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=10**8 ) while True: @@ -85,19 +90,11 @@ class Video: if len(self.currentFrame) == 0: self.frameBuffer.put((self.frameNo-1, self.lastFrame)) continue - except AttributeError as e: - self.parent.showMessage( - msg='%s couldn\'t be loaded. ' - 'This is a fatal error.' % os.path.basename( - self.videoPath - ), - detail=str(e), - icon='Warning' - ) - self.parent.stopVideo() + except AttributeError: + Video.threadError = ComponentError(self.component, 'video') break - self.currentFrame = pipe.stdout.read(self.chunkSize) + self.currentFrame = self.pipe.stdout.read(self.chunkSize) if len(self.currentFrame) != 0: self.frameBuffer.put((self.frameNo, self.currentFrame)) self.lastFrame = self.currentFrame @@ -143,13 +140,11 @@ class Component(Component): self.page.spinBox_volume.setEnabled(False) super().update() - def previewRender(self, previewWorker): - width = int(self.settings.value('outputWidth')) - height = int(self.settings.value('outputHeight')) - self.updateChunksize(width, height) - frame = self.getPreviewFrame(width, height) + def previewRender(self): + self.updateChunksize() + frame = self.getPreviewFrame(self.width, self.height) if not frame: - return BlankFrame(width, height) + return BlankFrame(self.width, self.height) else: return frame @@ -184,23 +179,23 @@ class Component(Component): def preFrameRender(self, **kwargs): super().preFrameRender(**kwargs) - width = int(self.settings.value('outputWidth')) - height = int(self.settings.value('outputHeight')) - self.blankFrame_ = BlankFrame(width, height) - self.updateChunksize(width, height) + self.updateChunksize() self.video = Video( ffmpeg=self.core.FFMPEG_BIN, videoPath=self.videoPath, - width=width, height=height, chunkSize=self.chunkSize, + width=self.width, height=self.height, chunkSize=self.chunkSize, frameRate=int(self.settings.value("outputFrameRate")), parent=self.parent, loopVideo=self.loopVideo, component=self, scale=self.scale ) if os.path.exists(self.videoPath) else None - def frameRender(self, layerNo, frameNo): - if self.video: - return self.video.frame(frameNo) - else: - return self.blankFrame_ + def frameRender(self, frameNo): + if Video.threadError is not None: + raise Video.threadError + return self.video.frame(frameNo) + + def renderFinished(self): + self.video.pipe.stdout.close() + self.video.pipe.send_signal(signal.SIGINT) def pickVideo(self): imgDir = self.settings.value("componentDir", os.path.expanduser("~")) @@ -230,20 +225,20 @@ class Component(Component): '-vframes', '1', ] pipe = openPipe( - command, stdout=subprocess.PIPE, + command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=10**8 ) byteFrame = pipe.stdout.read(self.chunkSize) - frame = finalizeFrame(self, byteFrame, width, height) pipe.stdout.close() - pipe.kill() + pipe.send_signal(signal.SIGINT) + frame = finalizeFrame(self, byteFrame, width, height) return frame - def updateChunksize(self, width, height): + def updateChunksize(self): if self.scale != 100 and not self.distort: - width, height = scale(self.scale, width, height, int) - self.chunkSize = 4*width*height + width, height = scale(self.scale, self.width, self.height, int) + self.chunkSize = 4 * width * height def command(self, arg): if '=' in arg: diff --git a/src/preview_thread.py b/src/preview_thread.py index 9917e4b..0a6a856 100644 --- a/src/preview_thread.py +++ b/src/preview_thread.py @@ -59,7 +59,7 @@ class Worker(QtCore.QObject): components = nextPreviewInformation["components"] for component in reversed(components): try: - newFrame = component.previewRender(self) + newFrame = component.previewRender() frame = Image.alpha_composite( frame, newFrame ) diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py index 8d63659..2fffc7b 100644 --- a/src/toolkit/ffmpeg.py +++ b/src/toolkit/ffmpeg.py @@ -252,7 +252,7 @@ def getAudioDuration(filename): return duration -def readAudioFile(filename, parent): +def readAudioFile(filename, videoWorker): ''' Creates the completeAudioArray given to components and used to draw the classic visualizer. @@ -296,8 +296,8 @@ def readAudioFile(filename, parent): if lastPercent != percent: string = 'Loading audio file: '+str(percent)+'%' - parent.progressBarSetText.emit(string) - parent.progressBarUpdate.emit(percent) + videoWorker.progressBarSetText.emit(string) + videoWorker.progressBarUpdate.emit(percent) lastPercent = percent diff --git a/src/video_thread.py b/src/video_thread.py index c5a3c09..8c7d585 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -60,8 +60,7 @@ class Worker(QtCore.QObject): audioI = self.compositeQueue.get() bgI = int(audioI / self.sampleSize) frame = None - for compNo, comp in reversed(list(enumerate(self.components))): - layerNo = len(self.components) - compNo - 1 + for layerNo, comp in enumerate(reversed((self.components))): if layerNo in self.staticComponents: if self.staticComponents[layerNo] is None: # this layer was merged into a following layer @@ -76,10 +75,10 @@ class Worker(QtCore.QObject): else: # animated component if frame is None: # bottom-most layer - frame = comp.frameRender(compNo, bgI) + frame = comp.frameRender(bgI) else: frame = Image.alpha_composite( - frame, comp.frameRender(compNo, bgI) + frame, comp.frameRender(bgI) ) self.renderQueue.put([audioI, frame]) @@ -185,7 +184,7 @@ class Worker(QtCore.QObject): break if 'static' in compProps: self.staticComponents[compNo] = \ - comp.frameRender(compNo, 0).copy() + comp.frameRender(0).copy() if self.canceled: if canceledByComponent: @@ -290,8 +289,11 @@ class Worker(QtCore.QObject): print(self.out_pipe.stderr.read()) self.out_pipe.stderr.close() self.error = True - # out_pipe.terminate() # don't terminate ffmpeg too early self.out_pipe.wait() + + for comp in reversed(self.components): + comp.renderFinished() + if self.canceled: print("Export Canceled") try: -- cgit v1.2.3 From 6ecb6df23628de65c9efd8cac4810fdf74238c3d Mon Sep 17 00:00:00 2001 From: tassaron Date: Thu, 27 Jul 2017 22:15:41 -0400 Subject: some minor bugfixes --- src/component.py | 5 +++-- src/components/sound.py | 3 --- src/components/video.py | 14 +++++++++----- src/mainwindow.py | 2 +- src/video_thread.py | 2 +- 5 files changed, 14 insertions(+), 12 deletions(-) (limited to 'src/components/sound.py') diff --git a/src/component.py b/src/component.py index 1c5ccb3..03023e7 100644 --- a/src/component.py +++ b/src/component.py @@ -33,7 +33,7 @@ class ComponentMetaclass(type(QtCore.QObject)): return func(self, *args, **kwargs) except Exception as e: try: - if e.__name__.startswith('Component'): + if e.__class__.__name__.startswith('Component'): raise else: raise ComponentError(self, 'renderer') @@ -213,7 +213,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): image = BlankFrame(self.width, self.height) return image - def renderFinished(self): + def postFrameRender(self): pass # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ @@ -456,4 +456,5 @@ class ComponentError(RuntimeError): ) super().__init__(string) + caller.lockError(string) caller._error.emit(string, detail) diff --git a/src/components/sound.py b/src/components/sound.py index aff43d3..26ecf93 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -21,9 +21,6 @@ class Component(Component): 'sound': None, }) - def preFrameRender(self, **kwargs): - pass - def properties(self): props = ['static', 'audio'] if not os.path.exists(self.sound): diff --git a/src/components/video.py b/src/components/video.py index 48ac557..b2487c1 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -59,7 +59,7 @@ class Video: self.thread = threading.Thread( target=self.fillBuffer, - name=self.__doc__ + name='Video Frame-Fetcher' ) self.thread.daemon = True self.thread.start() @@ -150,6 +150,10 @@ class Component(Component): def properties(self): props = [] + if hasattr(self.parent, 'window'): + outputFile = self.parent.window.lineEdit_outputFile.text() + else: + outputFile = str(self.parent.args.output) if not self.videoPath: self.lockError("There is no video selected.") @@ -157,9 +161,7 @@ class Component(Component): self.lockError("Could not identify an audio stream in this video.") elif not os.path.exists(self.videoPath): self.lockError("The video selected does not exist!") - elif (os.path.realpath(self.videoPath) == - os.path.realpath( - self.parent.window.lineEdit_outputFile.text())): + elif os.path.realpath(self.videoPath) == os.path.realpath(outputFile): self.lockError("Input and output paths match.") if self.useAudio: @@ -193,7 +195,7 @@ class Component(Component): raise Video.threadError return self.video.frame(frameNo) - def renderFinished(self): + def postFrameRender(self): self.video.pipe.stdout.close() self.video.pipe.send_signal(signal.SIGINT) @@ -238,6 +240,8 @@ class Component(Component): def updateChunksize(self): if self.scale != 100 and not self.distort: width, height = scale(self.scale, self.width, self.height, int) + else: + width, height = self.width, self.height self.chunkSize = 4 * width * height def command(self, arg): diff --git a/src/mainwindow.py b/src/mainwindow.py index e478d19..070131c 100644 --- a/src/mainwindow.py +++ b/src/mainwindow.py @@ -54,7 +54,7 @@ class PreviewWindow(QtWidgets.QLabel): def threadError(self, msg): self.parent.showMessage( msg=msg, - icon='Warning', + icon='Critical', parent=self ) diff --git a/src/video_thread.py b/src/video_thread.py index 8c7d585..32e8a38 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -292,7 +292,7 @@ class Worker(QtCore.QObject): self.out_pipe.wait() for comp in reversed(self.components): - comp.renderFinished() + comp.postFrameRender() if self.canceled: print("Export Canceled") -- cgit v1.2.3 From c07f2426ceeada205fdacbfba66329179a74a1dc Mon Sep 17 00:00:00 2001 From: tassaron Date: Sat, 19 Aug 2017 18:32:12 -0400 Subject: fixed issues with undoing relative widgets --- src/component.py | 198 +++++++++++++++++++++++++++++++++------------ src/components/color.py | 2 - src/components/image.py | 2 - src/components/life.py | 1 - src/components/sound.py | 1 - src/components/spectrum.py | 4 +- src/components/text.py | 1 - src/components/video.py | 2 - src/components/waveform.py | 2 +- src/core.py | 11 +-- src/gui/actions.py | 11 ++- src/gui/mainwindow.py | 4 +- src/gui/presetmanager.py | 4 + src/gui/preview_thread.py | 2 +- src/gui/preview_win.py | 2 +- src/main.py | 2 +- src/toolkit/common.py | 47 +++++++++-- 17 files changed, 215 insertions(+), 81 deletions(-) (limited to 'src/components/sound.py') diff --git a/src/component.py b/src/component.py index 1fe9237..ba86422 100644 --- a/src/component.py +++ b/src/component.py @@ -9,6 +9,7 @@ import sys import math import time import logging +from copy import copy from toolkit.frame import BlankFrame from toolkit import ( @@ -113,14 +114,20 @@ class ComponentMetaclass(type(QtCore.QObject)): def presetWrapper(self, *args): with openingPreset(self): - return func(self, *args) + try: + return func(self, *args) + except Exception: + try: + raise ComponentError(self, 'preset loader') + except ComponentError: + return return presetWrapper def updateWrapper(func): ''' - For undoable updates triggered by the user, - call _userUpdate() after the subclass's update() method. - For non-user updates, call _autoUpdate() + Calls _preUpdate before every subclass update(). + Afterwards, for non-user updates, calls _autoUpdate(). + For undoable updates triggered by the user, calls _userUpdate() ''' class wrap: def __init__(self, comp, auto): @@ -128,24 +135,57 @@ class ComponentMetaclass(type(QtCore.QObject)): self.auto = auto def __enter__(self): - pass + self.comp._preUpdate() def __exit__(self, *args): if self.auto or self.comp.openingPreset \ or not hasattr(self.comp.parent, 'undoStack'): + log.verbose('Automatic update') self.comp._autoUpdate() else: + log.verbose('User update') self.comp._userUpdate() def updateWrapper(self, **kwargs): - auto = False - if 'auto' in kwargs: - auto = kwargs['auto'] - + auto = kwargs['auto'] if 'auto' in kwargs else False with wrap(self, auto): - return func(self) + try: + return func(self) + except Exception: + try: + raise ComponentError(self, 'update method') + except ComponentError: + return return updateWrapper + def widgetWrapper(func): + '''Connects all widgets to update method after the subclass's method''' + class wrap: + def __init__(self, comp): + self.comp = comp + + def __enter__(self): + pass + + def __exit__(self, *args): + for widgetList in self.comp._allWidgets.values(): + for widget in widgetList: + log.verbose('Connecting %s' % str( + widget.__class__.__name__)) + connectWidget(widget, self.comp.update) + + def widgetWrapper(self, *args, **kwargs): + auto = kwargs['auto'] if 'auto' in kwargs else False + with wrap(self): + try: + return func(self, *args, **kwargs) + except Exception: + try: + raise ComponentError(self, 'widget creation') + except ComponentError: + return + return widgetWrapper + def __new__(cls, name, parents, attrs): if 'ui' not in attrs: # Use module name as ui filename by default @@ -153,13 +193,12 @@ class ComponentMetaclass(type(QtCore.QObject)): attrs['__module__'].split('.')[-1] )[0] - # if parents[0] == QtCore.QObject: else: decorate = ( 'names', # Class methods 'error', 'audio', 'properties', # Properties 'preFrameRender', 'previewRender', 'frameRender', 'command', - 'loadPreset', 'update' + 'loadPreset', 'update', 'widget', ) # Auto-decorate methods @@ -184,6 +223,8 @@ class ComponentMetaclass(type(QtCore.QObject)): attrs[key] = cls.loadPresetWrapper(attrs[key]) elif key == 'update': attrs[key] = cls.updateWrapper(attrs[key]) + elif key == 'widget' and parents[0] != QtCore.QObject: + attrs[key] = cls.widgetWrapper(attrs[key]) # Turn version string into a number try: @@ -224,23 +265,28 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): self.moduleIndex = moduleIndex self.compPos = compPos self.core = core - self.currentPreset = None - self.openingPreset = False + # STATUS VARIABLES + self.currentPreset = None + self._allWidgets = {} self._trackedWidgets = {} self._presetNames = {} self._commandArgs = {} self._colorWidgets = {} self._colorFuncs = {} self._relativeWidgets = {} - # pixel values stored as floats + # Pixel values stored as floats self._relativeValues = {} - # maximum values of spinBoxes at 1080p (Core.resolutions[0]) + # Maximum values of spinBoxes at 1080p (Core.resolutions[0]) self._relativeMaximums = {} + # LOCKING VARIABLES + self.openingPreset = False self._lockedProperties = None self._lockedError = None self._lockedSize = None + # If set to a dict, values are used as basis to update relative widgets + self.oldAttrs = None # Stop lengthy processes in response to this variable self.canceled = False @@ -338,21 +384,21 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): ''' self.parent = parent self.settings = parent.settings + log.verbose('Creating UI for %s #%s\'s widget' % ( + self.name, self.compPos + )) self.page = self.loadUi(self.__class__.ui) - # Connect widget signals - widgets = { + # Find all normal widgets which will be connected after subclass method + self._allWidgets = { 'lineEdit': self.page.findChildren(QtWidgets.QLineEdit), 'checkBox': self.page.findChildren(QtWidgets.QCheckBox), 'spinBox': self.page.findChildren(QtWidgets.QSpinBox), 'comboBox': self.page.findChildren(QtWidgets.QComboBox), } - widgets['spinBox'].extend( + self._allWidgets['spinBox'].extend( self.page.findChildren(QtWidgets.QDoubleSpinBox) ) - for widgetList in widgets.values(): - for widget in widgetList: - connectWidget(widget, self.update) def update(self): ''' @@ -427,10 +473,15 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ # "Private" Methods # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + def _preUpdate(self): + '''Happens before subclass update()''' + for attr in self._relativeWidgets: + self.updateRelativeWidget(attr) + def _userUpdate(self): - '''An undoable component update triggered by the user''' + '''Happens after subclass update() for an undoable update by user.''' oldWidgetVals = { - attr: getattr(self, attr) + attr: copy(getattr(self, attr)) for attr in self._trackedWidgets } newWidgetVals = { @@ -443,13 +494,12 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): for attr, val in newWidgetVals.items() if val != oldWidgetVals[attr] } - if modifiedWidgets: action = ComponentUpdate(self, oldWidgetVals, modifiedWidgets) self.parent.undoStack.push(action) def _autoUpdate(self): - '''An internal component update that is not undoable''' + '''Happens after subclass update() for an internal component update.''' newWidgetVals = { attr: getWidgetValue(widget) for attr, widget in self._trackedWidgets.items() @@ -459,12 +509,12 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): def setAttrs(self, attrDict): ''' - Sets attrs (linked to trackedWidgets) in this preset to + Sets attrs (linked to trackedWidgets) in this component to the values in the attrDict. Mutates certain widget values if needed ''' for attr, val in attrDict.items(): if attr in self._colorWidgets: - # Color Widgets: text stored as tuple & update the button color + # Color Widgets must have a tuple & have a button to update if type(val) is tuple: rgbTuple = val else: @@ -475,15 +525,25 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): self._colorWidgets[attr].setStyleSheet(btnStyle) setattr(self, attr, rgbTuple) - elif attr in self._relativeWidgets: - # Relative widgets: number scales to fit export resolution - self.updateRelativeWidget(attr) - setattr(self, attr, val) - else: # Normal tracked widget setattr(self, attr, val) + def setWidgetValues(self, attrDict): + ''' + Sets widgets defined by keys in trackedWidgets in this preset to + the values in the attrDict. + ''' + affectedWidgets = [ + self._trackedWidgets[attr] for attr in attrDict + ] + with blockSignals(affectedWidgets): + for attr, val in attrDict.items(): + widget = self._trackedWidgets[attr] + if attr in self._colorWidgets: + val = '%s,%s,%s' % val + setWidgetValue(widget, val) + def _sendUpdateSignal(self): if not self.core.openingProject: self.parent.drawPreview() @@ -499,6 +559,8 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): Optional args: 'presetNames': preset variable names to replace attr names 'commandArgs': arg keywords that differ from attr names + 'colorWidgets': identify attr as RGB tuple & update button CSS + 'relativeWidgets': change value proportionally to resolution NOTE: Any kwarg key set to None will selectively disable tracking. ''' @@ -542,6 +604,8 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): self._relativeMaximums[attr] = \ self._trackedWidgets[attr].maximum() self.updateRelativeWidgetMaximum(attr) + self._preUpdate() + self._autoUpdate() def pickColor(self, textWidget, button): '''Use color picker to get color input from the user.''' @@ -627,12 +691,28 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): def setRelativeWidget(self, attr, floatVal): '''Set a relative widget using a float''' pixelVal = self.pixelValForAttr(attr, floatVal) - self._trackedWidgets[attr].setValue(pixelVal) + with blockSignals(self._allWidgets): + self._trackedWidgets[attr].setValue(pixelVal) + self.update(auto=True) + + def getOldAttr(self, attr): + ''' + Returns previous state of this attr. Used to determine whether + a relative widget must be updated. Required because undoing/redoing + can make determining the 'previous' value tricky. + ''' + if self.oldAttrs is not None: + log.verbose('Using nonstandard oldAttr for %s' % attr) + return self.oldAttrs[attr] + else: + return getattr(self, attr) def updateRelativeWidget(self, attr): + '''Called by _preUpdate() for each relativeWidget before each update''' try: - oldUserValue = getattr(self, attr) - except AttributeError: + oldUserValue = self.getOldAttr(attr) + except (AttributeError, KeyError): + log.info('Using visible values as basis for relative widgets') oldUserValue = self._trackedWidgets[attr].value() newUserValue = self._trackedWidgets[attr].value() newRelativeVal = self.floatValForAttr(attr, newUserValue) @@ -645,11 +725,10 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): # means the pixel value needs to be updated log.debug('Updating %s #%s\'s relative widget: %s' % ( self.name, self.compPos, attr)) - self._trackedWidgets[attr].blockSignals(True) - self.updateRelativeWidgetMaximum(attr) - pixelVal = self.pixelValForAttr(attr, oldRelativeVal) - self._trackedWidgets[attr].setValue(pixelVal) - self._trackedWidgets[attr].blockSignals(False) + with blockSignals(self._trackedWidgets[attr]): + self.updateRelativeWidgetMaximum(attr) + pixelVal = self.pixelValForAttr(attr, oldRelativeVal) + self._trackedWidgets[attr].setValue(pixelVal) if attr not in self._relativeValues \ or oldUserValue != newUserValue: @@ -725,14 +804,22 @@ class ComponentUpdate(QtWidgets.QUndoCommand): parent.name, parent.compPos ) ) + self.undone = False self.parent = parent self.oldWidgetVals = { - attr: val + attr: copy(val) for attr, val in oldWidgetVals.items() if attr in modifiedVals } self.modifiedVals = modifiedVals + # Because relative widgets change themselves every update based on + # their previous value, we must store ALL their values in case of undo + self.redoRelativeWidgetVals = { + attr: copy(getattr(self.parent, attr)) + for attr in self.parent._relativeWidgets + } + # Determine if this update is mergeable self.id_ = -1 if len(self.modifiedVals) == 1: @@ -755,15 +842,26 @@ class ComponentUpdate(QtWidgets.QUndoCommand): return True def redo(self): + if self.undone: + log.debug('Redoing component update') + self.parent.setWidgetValues(self.modifiedVals) self.parent.setAttrs(self.modifiedVals) - self.parent._sendUpdateSignal() + if self.undone: + self.parent.oldAttrs = self.redoRelativeWidgetVals + self.parent.update(auto=True) + self.parent.oldAttrs = None + else: + self.undoRelativeWidgetVals = { + attr: copy(getattr(self.parent, attr)) + for attr in self.parent._relativeWidgets + } + self.parent._sendUpdateSignal() def undo(self): + log.debug('Undoing component update') + self.undone = True + self.parent.oldAttrs = self.undoRelativeWidgetVals + self.parent.setWidgetValues(self.oldWidgetVals) self.parent.setAttrs(self.oldWidgetVals) - with blockSignals(self.parent): - for attr, val in self.oldWidgetVals.items(): - widget = self.parent._trackedWidgets[attr] - if attr in self.parent._colorWidgets: - val = '%s,%s,%s' % val - setWidgetValue(widget, val) - self.parent._sendUpdateSignal() + self.parent.update(auto=True) + self.parent.oldAttrs = None diff --git a/src/components/color.py b/src/components/color.py index d09cee8..a55aa10 100644 --- a/src/components/color.py +++ b/src/components/color.py @@ -82,8 +82,6 @@ class Component(Component): self.page.pushButton_color2.setEnabled(False) self.page.fillWidget.setCurrentIndex(fillType) - super().update() - def previewRender(self): return self.drawFrame(self.width, self.height) diff --git a/src/components/image.py b/src/components/image.py index 63bee1a..c57b69c 100644 --- a/src/components/image.py +++ b/src/components/image.py @@ -84,7 +84,6 @@ class Component(Component): if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.page.lineEdit_image.setText(filename) - self.update() def command(self, arg): if '=' in arg: @@ -123,4 +122,3 @@ class Component(Component): else: scaleBox.setVisible(True) stretchScaleBox.setVisible(False) - super().update() diff --git a/src/components/life.py b/src/components/life.py index 2383d30..76d2c5f 100644 --- a/src/components/life.py +++ b/src/components/life.py @@ -53,7 +53,6 @@ class Component(Component): if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.page.lineEdit_image.setText(filename) - self.update() def shiftGrid(self, d): def newGrid(Xchange, Ychange): diff --git a/src/components/sound.py b/src/components/sound.py index 26ecf93..b86f40c 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -53,7 +53,6 @@ class Component(Component): if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.page.lineEdit_sound.setText(filename) - self.update() def commandHelp(self): print('Path to audio file:\n path=/filepath/to/sound.ogg') diff --git a/src/components/spectrum.py b/src/components/spectrum.py index 89130a2..2b98dc2 100644 --- a/src/components/spectrum.py +++ b/src/components/spectrum.py @@ -76,8 +76,6 @@ class Component(Component): else: self.page.checkBox_mono.setEnabled(True) - super().update() - def previewRender(self): changedSize = self.updateChunksize() if not changedSize \ @@ -138,7 +136,7 @@ class Component(Component): '-r', self.settings.value("outputFrameRate"), '-ss', "{0:.3f}".format(startPt), '-i', - os.path.join(self.core.wd, 'background.png') + self.core.junkStream if genericPreview else inputFile, '-f', 'image2pipe', '-pix_fmt', 'rgba', diff --git a/src/components/text.py b/src/components/text.py index d3afd5c..92f0599 100644 --- a/src/components/text.py +++ b/src/components/text.py @@ -68,7 +68,6 @@ class Component(Component): self.page.spinBox_shadY.setHidden(True) self.page.label_shadBlur.setHidden(True) self.page.spinBox_shadBlur.setHidden(True) - super().update() def centerXY(self): self.setRelativeWidget('xPosition', 0.5) diff --git a/src/components/video.py b/src/components/video.py index a189f60..9c0d608 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -52,7 +52,6 @@ class Component(Component): else: self.page.label_volume.setEnabled(False) self.page.spinBox_volume.setEnabled(False) - super().update() def previewRender(self): self.updateChunksize() @@ -119,7 +118,6 @@ class Component(Component): if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.page.lineEdit_video.setText(filename) - self.update() def getPreviewFrame(self, width, height): if not self.videoPath or not os.path.exists(self.videoPath): diff --git a/src/components/waveform.py b/src/components/waveform.py index 0743e55..5c02bbf 100644 --- a/src/components/waveform.py +++ b/src/components/waveform.py @@ -98,7 +98,7 @@ class Component(Component): '-r', self.settings.value("outputFrameRate"), '-ss', "{0:.3f}".format(startPt), '-i', - os.path.join(self.core.wd, 'background.png') + self.core.junkStream if genericPreview else inputFile, '-f', 'image2pipe', '-pix_fmt', 'rgba', diff --git a/src/core.py b/src/core.py index d9499f7..169716c 100644 --- a/src/core.py +++ b/src/core.py @@ -13,7 +13,7 @@ import toolkit log = logging.getLogger('AVP.Core') -STDOUT_LOGLVL = logging.WARNING +STDOUT_LOGLVL = logging.VERBOSE FILE_LOGLVL = logging.DEBUG @@ -81,10 +81,7 @@ class Core: component = self.modules[moduleIndex].Component( moduleIndex, compPos, self ) - # init component's widget for loading/saving presets component.widget(loader) - # use autoUpdate() method before update() this 1 time to set attrs - component._autoUpdate() else: moduleIndex = -1 log.debug( @@ -186,9 +183,8 @@ class Core: if hasattr(loader, 'window'): for widget, value in data['WindowFields']: widget = eval('loader.window.%s' % widget) - widget.blockSignals(True) - toolkit.setWidgetValue(widget, value) - widget.blockSignals(False) + with toolkit.blockSignals(widget): + toolkit.setWidgetValue(widget, value) for key, value in data['Settings']: Core.settings.setValue(key, value) @@ -474,6 +470,7 @@ class Core: 'logDir': os.path.join(dataDir, 'log'), 'presetDir': os.path.join(dataDir, 'presets'), 'componentsPath': os.path.join(wd, 'components'), + 'junkStream': os.path.join(wd, 'gui', 'background.png'), 'encoderOptions': encoderOptions, 'resolutions': [ '1920x1080', diff --git a/src/gui/actions.py b/src/gui/actions.py index 0fe97f2..1444569 100644 --- a/src/gui/actions.py +++ b/src/gui/actions.py @@ -20,11 +20,20 @@ class AddComponent(QUndoCommand): self.parent = parent self.moduleI = moduleI self.compI = compI + self.comp = None def redo(self): - self.parent.core.insertComponent(self.compI, self.moduleI, self.parent) + if self.comp is None: + self.parent.core.insertComponent( + self.compI, self.moduleI, self.parent) + else: + # inserting previously-created component + self.parent.core.insertComponent( + self.compI, self.comp, self.parent) + def undo(self): + self.comp = self.parent.core.selectedComponents[self.compI] self.parent._removeComponent(self.compI) diff --git a/src/gui/mainwindow.py b/src/gui/mainwindow.py index 8000b3b..76c53af 100644 --- a/src/gui/mainwindow.py +++ b/src/gui/mainwindow.py @@ -25,7 +25,7 @@ from toolkit import ( ) -log = logging.getLogger('AVP.MainWindow') +log = logging.getLogger('AVP.Gui.MainWindow') class MainWindow(QtWidgets.QMainWindow): @@ -76,7 +76,7 @@ class MainWindow(QtWidgets.QMainWindow): # Create the preview window and its thread, queues, and timers log.debug('Creating preview window') self.previewWindow = PreviewWindow(self, os.path.join( - Core.wd, "background.png")) + Core.wd, 'gui', "background.png")) window.verticalLayout_previewWrapper.addWidget(self.previewWindow) log.debug('Starting preview thread') diff --git a/src/gui/presetmanager.py b/src/gui/presetmanager.py index dce5333..befa7cd 100644 --- a/src/gui/presetmanager.py +++ b/src/gui/presetmanager.py @@ -5,12 +5,16 @@ from PyQt5 import QtCore, QtWidgets import string import os +import logging from toolkit import badName from core import Core from gui.actions import * +log = logging.getLogger('AVP.Gui.PresetManager') + + class PresetManager(QtWidgets.QDialog): def __init__(self, window, parent): super().__init__(parent.window) diff --git a/src/gui/preview_thread.py b/src/gui/preview_thread.py index 9615884..33a9e7a 100644 --- a/src/gui/preview_thread.py +++ b/src/gui/preview_thread.py @@ -14,7 +14,7 @@ from toolkit.frame import Checkerboard from toolkit import disableWhenOpeningProject -log = logging.getLogger("AVP.PreviewThread") +log = logging.getLogger("AVP.Gui.PreviewThread") class Worker(QtCore.QObject): diff --git a/src/gui/preview_win.py b/src/gui/preview_win.py index 40c19c6..c6b9a32 100644 --- a/src/gui/preview_win.py +++ b/src/gui/preview_win.py @@ -7,7 +7,7 @@ class PreviewWindow(QtWidgets.QLabel): Paints the preview QLabel in MainWindow and maintains the aspect ratio when the window is resized. ''' - log = logging.getLogger('AVP.PreviewWindow') + log = logging.getLogger('AVP.Gui.PreviewWindow') def __init__(self, parent, img): super(PreviewWindow, self).__init__() diff --git a/src/main.py b/src/main.py index c1278da..6d18af3 100644 --- a/src/main.py +++ b/src/main.py @@ -6,7 +6,7 @@ import logging from __init__ import wd -log = logging.getLogger('AVP.Entrypoint') +log = logging.getLogger('AVP.Main') def main(): diff --git a/src/toolkit/common.py b/src/toolkit/common.py index 51ad023..74143e8 100644 --- a/src/toolkit/common.py +++ b/src/toolkit/common.py @@ -6,19 +6,53 @@ import string import os import sys import subprocess +import logging +from copy import copy from collections import OrderedDict +log = logging.getLogger('AVP.Toolkit.Common') + + class blockSignals: - '''A context manager to temporarily block a Qt widget from updating''' - def __init__(self, widget): - self.widget = widget + ''' + Context manager to temporarily block list of QtWidgets from updating, + and guarantee restoring the previous state afterwards. + ''' + def __init__(self, widgets): + if type(widgets) is dict: + self.widgets = concatDictVals(widgets) + else: + self.widgets = ( + widgets if hasattr(widgets, '__iter__') + else [widgets] + ) def __enter__(self): - self.widget.blockSignals(True) + log.verbose('Blocking signals for %s' % ", ".join([ + str(w.__class__.__name__) for w in self.widgets + ])) + self.oldStates = [w.signalsBlocked() for w in self.widgets] + for w in self.widgets: + w.blockSignals(True) def __exit__(self, *args): - self.widget.blockSignals(False) + log.verbose('Resetting blockSignals to %s' % sum(self.oldStates)) + for w, state in zip(self.widgets, self.oldStates): + w.blockSignals(state) + + +def concatDictVals(d): + '''Concatenates all values in given dict into one list.''' + key, value = d.popitem() + d[key] = value + final = copy(value) + if type(final) is not list: + final = [final] + final.extend([val for val in d.values()]) + else: + value.extend([item for val in d.values() for item in val]) + return final def badName(name): @@ -119,12 +153,14 @@ def connectWidget(widget, func): elif type(widget) == QtWidgets.QComboBox: widget.currentIndexChanged.connect(func) else: + log.warning('Failed to connect %s ' % str(widget.__class__.__name__)) return False return True def setWidgetValue(widget, val): '''Generic setValue method for use with any typical QtWidget''' + log.verbose('Setting %s to %s' % (str(widget.__class__.__name__), val)) if type(widget) == QtWidgets.QLineEdit: widget.setText(val) elif type(widget) == QtWidgets.QSpinBox \ @@ -135,6 +171,7 @@ def setWidgetValue(widget, val): elif type(widget) == QtWidgets.QComboBox: widget.setCurrentIndex(val) else: + log.warning('Failed to set %s ' % str(widget.__class__.__name__)) return False return True -- cgit v1.2.3 From 6bf8a553d6170e0ca6e7d2002e46ae327a6e5e81 Mon Sep 17 00:00:00 2001 From: tassaron Date: Sun, 20 Aug 2017 18:36:43 -0400 Subject: don't merge undos when setting text with a button plus changes to life.py for pep8 compliance --- src/component.py | 5 ++++- src/components/image.py | 2 ++ src/components/life.py | 46 +++++++++++++++++++++++++++------------------- src/components/sound.py | 2 ++ src/components/video.py | 2 ++ src/gui/actions.py | 1 - 6 files changed, 37 insertions(+), 21 deletions(-) (limited to 'src/components/sound.py') diff --git a/src/component.py b/src/component.py index 0ff2fbd..1f55a19 100644 --- a/src/component.py +++ b/src/component.py @@ -285,6 +285,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): # LOCKING VARIABLES self.openingPreset = False + self.mergeUndo = True self._lockedProperties = None self._lockedError = None self._lockedSize = None @@ -587,10 +588,12 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): if kwarg == 'colorWidgets': def makeColorFunc(attr): def pickColor_(): + self.mergeUndo = False self.pickColor( self._trackedWidgets[attr], self._colorWidgets[attr] ) + self.mergeUndo = True return pickColor_ self._colorFuncs = { attr: makeColorFunc(attr) for attr in kwargs[kwarg] @@ -850,7 +853,7 @@ class ComponentUpdate(QtWidgets.QUndoCommand): # Determine if this update is mergeable self.id_ = -1 - if len(self.modifiedVals) == 1: + if len(self.modifiedVals) == 1 and self.parent.mergeUndo: attr, val = self.modifiedVals.popitem() self.id_ = sum([ord(letter) for letter in attr[-14:]]) self.modifiedVals[attr] = val diff --git a/src/components/image.py b/src/components/image.py index c57b69c..dd363bf 100644 --- a/src/components/image.py +++ b/src/components/image.py @@ -83,7 +83,9 @@ class Component(Component): "Image Files (%s)" % " ".join(self.core.imageFormats)) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) + self.mergeUndo = False self.page.lineEdit_image.setText(filename) + self.mergeUndo = True def command(self, arg): if '=' in arg: diff --git a/src/components/life.py b/src/components/life.py index 5d00987..d4a455d 100644 --- a/src/components/life.py +++ b/src/components/life.py @@ -35,6 +35,7 @@ class Component(Component): self.page.toolButton_left, self.page.toolButton_right, ) + def shiftFunc(i): def shift(): self.shiftGrid(i) @@ -52,7 +53,9 @@ class Component(Component): "Image Files (%s)" % " ".join(self.core.imageFormats)) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) + self.mergeUndo = False self.page.lineEdit_image.setText(filename) + self.mergeUndo = True def shiftGrid(self, d): def newGrid(Xchange, Ychange): @@ -197,7 +200,7 @@ class Component(Component): # Circle if shape == 'circle': drawer.ellipse(outlineShape, fill=self.color) - drawer.ellipse(smallerShape, fill=(0,0,0,0)) + drawer.ellipse(smallerShape, fill=(0, 0, 0, 0)) # Lilypad elif shape == 'lilypad': @@ -207,9 +210,9 @@ class Component(Component): elif shape == 'pac-man': drawer.pieslice(outlineShape, 35, 320, fill=self.color) - hX, hY = scale(50, self.pxWidth, self.pxHeight, int) # halfline - tX, tY = scale(33, self.pxWidth, self.pxHeight, int) # thirdline - qX, qY = scale(20, self.pxWidth, self.pxHeight, int) # quarterline + hX, hY = scale(50, self.pxWidth, self.pxHeight, int) # halfline + tX, tY = scale(33, self.pxWidth, self.pxHeight, int) # thirdline + qX, qY = scale(20, self.pxWidth, self.pxHeight, int) # quarterline # Path if shape == 'path': @@ -245,19 +248,19 @@ class Component(Component): sect = ( (drawPtX, drawPtY + hY), (drawPtX + self.pxWidth, - drawPtY + self.pxHeight) + drawPtY + self.pxHeight) ) elif direction == 'left': sect = ( (drawPtX, drawPtY), (drawPtX + hX, - drawPtY + self.pxHeight) + drawPtY + self.pxHeight) ) elif direction == 'right': sect = ( (drawPtX + hX, drawPtY), (drawPtX + self.pxWidth, - drawPtY + self.pxHeight) + drawPtY + self.pxHeight) ) drawer.rectangle(sect, fill=self.color) @@ -287,20 +290,25 @@ class Component(Component): # Peace elif shape == 'peace': - line = ( - (drawPtX + hX - int(tenthX / 2), drawPtY + int(tenthY / 2)), + line = (( + drawPtX + hX - int(tenthX / 2), drawPtY + int(tenthY / 2)), (drawPtX + hX + int(tenthX / 2), - drawPtY + self.pxHeight - int(tenthY / 2)) + drawPtY + self.pxHeight - int(tenthY / 2)) ) drawer.ellipse(outlineShape, fill=self.color) - drawer.ellipse(smallerShape, fill=(0,0,0,0)) + drawer.ellipse(smallerShape, fill=(0, 0, 0, 0)) drawer.rectangle(line, fill=self.color) - slantLine = lambda difference: ( - ((drawPtX + difference), - (drawPtY + self.pxHeight - qY)), - ((drawPtX + hX), - (drawPtY + hY)), - ) + + def slantLine(difference): + return ( + (drawPtX + difference), + (drawPtY + self.pxHeight - qY) + ), + ( + (drawPtX + hX), + (drawPtY + hY) + ) + drawer.line( slantLine(qX), fill=self.color, @@ -337,13 +345,13 @@ class Component(Component): for x in range(self.pxWidth, self.width, self.pxWidth): drawer.rectangle( ((x, 0), - (x + w, self.height)), + (x + w, self.height)), fill=self.color, ) for y in range(self.pxHeight, self.height, self.pxHeight): drawer.rectangle( ((0, y), - (self.width, y + h)), + (self.width, y + h)), fill=self.color, ) diff --git a/src/components/sound.py b/src/components/sound.py index b86f40c..18d2a65 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -52,7 +52,9 @@ class Component(Component): "Audio Files (%s)" % " ".join(self.core.audioFormats)) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) + self.mergeUndo = False self.page.lineEdit_sound.setText(filename) + self.mergeUndo = True def commandHelp(self): print('Path to audio file:\n path=/filepath/to/sound.ogg') diff --git a/src/components/video.py b/src/components/video.py index 9c0d608..e6486ea 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -117,7 +117,9 @@ class Component(Component): ) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) + self.mergeUndo = False self.page.lineEdit_video.setText(filename) + self.mergeUndo = True def getPreviewFrame(self, width, height): if not self.videoPath or not os.path.exists(self.videoPath): diff --git a/src/gui/actions.py b/src/gui/actions.py index f101bd7..ebd9702 100644 --- a/src/gui/actions.py +++ b/src/gui/actions.py @@ -32,7 +32,6 @@ class AddComponent(QUndoCommand): self.parent.core.insertComponent( self.compI, self.comp, self.parent) - def undo(self): self.comp = self.parent.core.selectedComponents[self.compI] self.parent._removeComponent(self.compI) -- cgit v1.2.3 From 05d2ebc3c69f5a876d602004f69202c5ba8b09f7 Mon Sep 17 00:00:00 2001 From: tassaron Date: Fri, 22 Apr 2022 17:09:50 -0400 Subject: make pip-installable as a package --- MANIFEST.in | 7 ++++++ setup.py | 61 ++++++++++++++++++++++++++-------------------- src/__init__.py | 6 ++--- src/__main__.py | 4 +-- src/component.py | 4 +-- src/components/color.py | 4 +-- src/components/image.py | 4 +-- src/components/life.py | 4 +-- src/components/original.py | 4 +-- src/components/sound.py | 4 +-- src/components/spectrum.py | 8 +++--- src/components/text.py | 4 +-- src/components/video.py | 8 +++--- src/components/waveform.py | 8 +++--- src/core.py | 12 ++++----- src/gui/actions.py | 2 +- src/gui/mainwindow.py | 13 +++++----- src/gui/presetmanager.py | 6 ++--- src/gui/preview_thread.py | 4 +-- src/main.py | 11 ++++----- src/toolkit/__init__.py | 2 +- src/toolkit/ffmpeg.py | 8 +++--- src/toolkit/frame.py | 2 +- src/video_thread.py | 8 +++--- 24 files changed, 106 insertions(+), 92 deletions(-) create mode 100644 MANIFEST.in (limited to 'src/components/sound.py') diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..2b2d794 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +recursive-include src/tests +include src/components/*.ui +include src/gui/*.ui +include src/gui/background.png +include src/encoder-options.json +global-exclude src/components/__template__.ui +global-exclude *.py[cod] diff --git a/setup.py b/setup.py index cdf4c4a..5e01229 100644 --- a/setup.py +++ b/setup.py @@ -1,29 +1,39 @@ -from setuptools import setup -import os +from setuptools import setup, find_packages +from importlib import import_module +from os import path +import re -__version__ = '2.0.0rc5' +def getTextFromFile(filename, fallback): + try: + with open( + path.join(path.abspath(path.dirname(__file__)), filename), encoding="utf-8" + ) as f: + output = f.read() + except Exception: + output = fallback + return output -def package_files(directory): - paths = [] - for (path, directories, filenames) in os.walk(directory): - for filename in filenames: - paths.append(os.path.join('..', path, filename)) - return paths +PACKAGE_NAME = 'avp' +SOURCE_DIRECTORY = 'src' +SOURCE_PACKAGE_REGEX = re.compile(rf'^{SOURCE_DIRECTORY}') +PACKAGE_DESCRIPTION = 'Create audio visualization videos from a GUI or commandline' + + +avp = import_module(SOURCE_DIRECTORY) +source_packages = find_packages(include=[SOURCE_DIRECTORY, f'{SOURCE_DIRECTORY}.*']) +proj_packages = [SOURCE_PACKAGE_REGEX.sub(PACKAGE_NAME, name) for name in source_packages] setup( name='audio_visualizer_python', - version=__version__, + version=avp.__version__, url='https://github.com/djfun/audio-visualizer-python/tree/feature-newgui', license='MIT', - description='Create audio visualization videos from a GUI or commandline', - long_description="Create customized audio visualization videos and save " - "them as Projects to continue editing later. Different components can " - "be added and layered to add visualizers, images, videos, gradients, " - "text, etc. Use Projects created in the GUI with commandline mode to " - "automate your video production workflow without any complex syntax.", + description=PACKAGE_DESCRIPTION, + author=getTextFromFile('AUTHORS', 'djfun, tassaron'), + long_description=getTextFromFile('README.md', PACKAGE_DESCRIPTION), classifiers=[ 'Development Status :: 4 - Beta', 'License :: OSI Approved :: MIT License', @@ -35,19 +45,18 @@ setup( 'visualizer', 'visualization', 'commandline video', 'video editor', 'ffmpeg', 'podcast' ], - packages=[ - 'avpython', - 'avpython.toolkit', - 'avpython.components' + packages=proj_packages, + package_dir={PACKAGE_NAME: SOURCE_DIRECTORY}, + include_package_data=True, + install_requires=[ + 'Pillow-SIMD', + 'PyQt5', + 'numpy', + 'pytest' ], - package_dir={'avpython': 'src'}, - package_data={ - 'avpython': package_files('src'), - }, - install_requires=['Pillow-SIMD', 'PyQt5', 'numpy'], entry_points={ 'gui_scripts': [ - 'avp = avpython.main:main' + f'avp = {PACKAGE_NAME}.main:main' ], } ) diff --git a/src/__init__.py b/src/__init__.py index 73f174a..08131ce 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -3,6 +3,9 @@ import os import logging +__version__ = '2.0.0rc6' + + class Logger(logging.getLoggerClass()): ''' Custom Logger class to handle custom VERBOSE log level. @@ -31,6 +34,3 @@ if getattr(sys, 'frozen', False): else: # unfrozen wd = os.path.dirname(os.path.realpath(__file__)) - -# make relative imports work when using /src as a package -sys.path.insert(0, wd) diff --git a/src/__main__.py b/src/__main__.py index 3babeae..3206bc8 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -1,5 +1,5 @@ -# Allows for launching with python3 -m avpython +# Allows for launching with python3 -m avp -from avpython.main import main +from .main import main main() diff --git a/src/component.py b/src/component.py index f3ee188..33c7657 100644 --- a/src/component.py +++ b/src/component.py @@ -11,8 +11,8 @@ import time import logging from copy import copy -from toolkit.frame import BlankFrame -from toolkit import ( +from .toolkit.frame import BlankFrame +from .toolkit import ( getWidgetValue, setWidgetValue, connectWidget, rgbFromString, blockSignals ) diff --git a/src/components/color.py b/src/components/color.py index 7d4f86d..6336194 100644 --- a/src/components/color.py +++ b/src/components/color.py @@ -4,8 +4,8 @@ from PyQt5.QtGui import QColor from PIL.ImageQt import ImageQt import os -from component import Component -from toolkit.frame import BlankFrame, FloodFrame, FramePainter, PaintColor +from ..component import Component +from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter, PaintColor class Component(Component): diff --git a/src/components/image.py b/src/components/image.py index dd363bf..42f9564 100644 --- a/src/components/image.py +++ b/src/components/image.py @@ -2,8 +2,8 @@ from PIL import Image, ImageDraw, ImageEnhance from PyQt5 import QtGui, QtCore, QtWidgets import os -from component import Component -from toolkit.frame import BlankFrame +from ..component import Component +from ..toolkit.frame import BlankFrame class Component(Component): diff --git a/src/components/life.py b/src/components/life.py index 7a610eb..94704bc 100644 --- a/src/components/life.py +++ b/src/components/life.py @@ -4,8 +4,8 @@ from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter import os import math -from component import Component -from toolkit.frame import BlankFrame, scale +from ..component import Component +from ..toolkit.frame import BlankFrame, scale class Component(Component): diff --git a/src/components/original.py b/src/components/original.py index f886374..80228fe 100644 --- a/src/components/original.py +++ b/src/components/original.py @@ -6,8 +6,8 @@ import os import time from copy import copy -from component import Component -from toolkit.frame import BlankFrame +from ..component import Component +from ..toolkit.frame import BlankFrame class Component(Component): diff --git a/src/components/sound.py b/src/components/sound.py index 18d2a65..118ea23 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -1,8 +1,8 @@ from PyQt5 import QtGui, QtCore, QtWidgets import os -from component import Component -from toolkit.frame import BlankFrame +from ..component import Component +from ..toolkit.frame import BlankFrame class Component(Component): diff --git a/src/components/spectrum.py b/src/components/spectrum.py index 6675f5b..d1f8fb6 100644 --- a/src/components/spectrum.py +++ b/src/components/spectrum.py @@ -6,10 +6,10 @@ import subprocess import time import logging -from component import Component -from toolkit.frame import BlankFrame, scale -from toolkit import checkOutput, connectWidget -from toolkit.ffmpeg import ( +from ..component import Component +from ..toolkit.frame import BlankFrame, scale +from ..toolkit import checkOutput, connectWidget +from ..toolkit.ffmpeg import ( openPipe, closePipe, getAudioDuration, FfmpegVideo, exampleSound ) diff --git a/src/components/text.py b/src/components/text.py index 32a108e..e8c5a9c 100644 --- a/src/components/text.py +++ b/src/components/text.py @@ -4,8 +4,8 @@ from PyQt5 import QtGui, QtCore, QtWidgets import os import logging -from component import Component -from toolkit.frame import FramePainter, PaintColor +from ..component import Component +from ..toolkit.frame import FramePainter, PaintColor log = logging.getLogger('AVP.Components.Text') diff --git a/src/components/video.py b/src/components/video.py index 8ad21b5..070940d 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -5,10 +5,10 @@ import math import subprocess import logging -from component import Component -from toolkit.frame import BlankFrame, scale -from toolkit.ffmpeg import openPipe, closePipe, testAudioStream, FfmpegVideo -from toolkit import checkOutput +from ..component import Component +from ..toolkit.frame import BlankFrame, scale +from ..toolkit.ffmpeg import openPipe, closePipe, testAudioStream, FfmpegVideo +from ..toolkit import checkOutput log = logging.getLogger('AVP.Components.Video') diff --git a/src/components/waveform.py b/src/components/waveform.py index cbfc47f..1a6035f 100644 --- a/src/components/waveform.py +++ b/src/components/waveform.py @@ -6,10 +6,10 @@ import math import subprocess import logging -from component import Component -from toolkit.frame import BlankFrame, scale -from toolkit import checkOutput -from toolkit.ffmpeg import ( +from ..component import Component +from ..toolkit.frame import BlankFrame, scale +from ..toolkit import checkOutput +from ..toolkit.ffmpeg import ( openPipe, closePipe, getAudioDuration, FfmpegVideo, exampleSound ) diff --git a/src/core.py b/src/core.py index d7445c9..bc6f9b4 100644 --- a/src/core.py +++ b/src/core.py @@ -9,12 +9,12 @@ import json from importlib import import_module import logging -import toolkit +from . import toolkit log = logging.getLogger('AVP.Core') STDOUT_LOGLVL = logging.WARNING -FILE_LOGLVL = None +FILE_LOGLVL = logging.ERROR class Core: @@ -47,7 +47,7 @@ class Core: yield name log.debug('Importing component modules') self.modules = [ - import_module('components.%s' % name) + import_module('.components.%s' % name, __package__) for name in findComponents() ] # store canonical module names and indexes @@ -426,7 +426,7 @@ class Core: def newVideoWorker(self, loader, audioFile, outputPath): '''loader is MainWindow or Command object which must own the thread''' - import video_thread + from . import video_thread self.videoThread = QtCore.QThread(loader) videoWorker = video_thread.Worker( loader, audioFile, outputPath, self.selectedComponents @@ -450,8 +450,8 @@ class Core: @classmethod def storeSettings(cls): '''Store settings/paths to directories as class variables''' - from __init__ import wd - from toolkit.ffmpeg import findFfmpeg + from .__init__ import wd + from .toolkit.ffmpeg import findFfmpeg cls.wd = wd dataDir = QtCore.QStandardPaths.writableLocation( diff --git a/src/gui/actions.py b/src/gui/actions.py index 8e867b9..eb7b953 100644 --- a/src/gui/actions.py +++ b/src/gui/actions.py @@ -5,7 +5,7 @@ from PyQt5.QtWidgets import QUndoCommand import os from copy import copy -from core import Core +from ..core import Core # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ diff --git a/src/gui/mainwindow.py b/src/gui/mainwindow.py index 75534c2..da8370d 100644 --- a/src/gui/mainwindow.py +++ b/src/gui/mainwindow.py @@ -16,12 +16,12 @@ import filecmp import time import logging -from core import Core -import gui.preview_thread as preview_thread -from gui.preview_win import PreviewWindow -from gui.presetmanager import PresetManager -from gui.actions import * -from toolkit import ( +from ..core import Core +from . import preview_thread +from .preview_win import PreviewWindow +from .presetmanager import PresetManager +from .actions import * +from ..toolkit import ( disableWhenEncoding, disableWhenOpeningProject, checkOutput, blockSignals ) @@ -65,7 +65,6 @@ class MainWindow(QtWidgets.QMainWindow): self.settings = Core.settings # Register clean-up functions - signal.signal(signal.SIGINT, self.terminate) atexit.register(self.cleanUp) # Create stack of undoable user actions diff --git a/src/gui/presetmanager.py b/src/gui/presetmanager.py index 2445760..1e47a7f 100644 --- a/src/gui/presetmanager.py +++ b/src/gui/presetmanager.py @@ -7,9 +7,9 @@ import string import os import logging -from toolkit import badName -from core import Core -from gui.actions import * +from ..toolkit import badName +from ..core import Core +from .actions import * log = logging.getLogger('AVP.Gui.PresetManager') diff --git a/src/gui/preview_thread.py b/src/gui/preview_thread.py index d3e0581..7829476 100644 --- a/src/gui/preview_thread.py +++ b/src/gui/preview_thread.py @@ -10,8 +10,8 @@ from queue import Queue, Empty import os import logging -from toolkit.frame import Checkerboard -from toolkit import disableWhenOpeningProject +from ..toolkit.frame import Checkerboard +from ..toolkit import disableWhenOpeningProject log = logging.getLogger("AVP.Gui.PreviewThread") diff --git a/src/main.py b/src/main.py index 126e4a8..5fabda3 100644 --- a/src/main.py +++ b/src/main.py @@ -3,7 +3,7 @@ import sys import os import logging -from __init__ import wd +from .__init__ import wd log = logging.getLogger('AVP.Main') @@ -12,6 +12,7 @@ log = logging.getLogger('AVP.Main') def main(): app = QtWidgets.QApplication(sys.argv) app.setApplicationName("audio-visualizer") + proj = None # Determine mode mode = 'GUI' @@ -23,19 +24,17 @@ def main(): else: # opening a project file with gui proj = sys.argv[1] - else: - # normal gui launch - proj = None # Launch program if mode == 'commandline': - from command import Command + from .command import Command main = Command() + main.parseArgs() log.debug("Finished creating command object") elif mode == 'GUI': - from gui.mainwindow import MainWindow + from .gui.mainwindow import MainWindow window = uic.loadUi(os.path.join(wd, "gui", "mainwindow.ui")) # window.adjustSize() diff --git a/src/toolkit/__init__.py b/src/toolkit/__init__.py index 3fca275..55e5f84 100644 --- a/src/toolkit/__init__.py +++ b/src/toolkit/__init__.py @@ -1 +1 @@ -from toolkit.common import * +from .common import * diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py index 419d491..3298c04 100644 --- a/src/toolkit/ffmpeg.py +++ b/src/toolkit/ffmpeg.py @@ -10,8 +10,8 @@ import signal from queue import PriorityQueue import logging -import core -from toolkit.common import checkOutput, pipeWrapper +from .. import core +from .common import checkOutput, pipeWrapper log = logging.getLogger('AVP.Toolkit.Ffmpeg') @@ -90,7 +90,7 @@ class FfmpegVideo: self.frameBuffer.task_done() def fillBuffer(self): - from component import ComponentError + from ..component import ComponentError if core.Core.logEnabled: logFilename = os.path.join( core.Core.logDir, 'render_%s.log' % str(self.component.compPos) @@ -144,7 +144,7 @@ def openPipe(commandList, **kwargs): def closePipe(pipe): pipe.stdout.close() - pipe.send_signal(signal.SIGINT) + pipe.send_signal(signal.SIGTERM) def findFfmpeg(): diff --git a/src/toolkit/frame.py b/src/toolkit/frame.py index 0e200b5..f2511fe 100644 --- a/src/toolkit/frame.py +++ b/src/toolkit/frame.py @@ -9,7 +9,7 @@ import os import math import logging -import core +from .. import core log = logging.getLogger('AVP.Toolkit.Frame') diff --git a/src/video_thread.py b/src/video_thread.py index 0a39f28..31331a3 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -19,9 +19,9 @@ import time import signal import logging -from component import ComponentError -from toolkit.frame import Checkerboard -from toolkit.ffmpeg import ( +from .component import ComponentError +from .toolkit.frame import Checkerboard +from .toolkit.ffmpeg import ( openPipe, readAudioFile, getAudioDuration, createFfmpegCommand ) @@ -400,7 +400,7 @@ class Worker(QtCore.QObject): comp.cancel() try: - self.out_pipe.send_signal(signal.SIGINT) + self.out_pipe.send_signal(signal.SIGTERM) except Exception: pass -- cgit v1.2.3