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 --- src/mainwindow.py | 81 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 48 insertions(+), 33 deletions(-) (limited to 'src/mainwindow.py') 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 = [ -- cgit v1.2.3 From 450b944b87487aa60a935bbeee3908e2a62cd45b Mon Sep 17 00:00:00 2001 From: tassaron Date: Thu, 20 Jul 2017 22:37:15 -0400 Subject: add component in context menu, del/ins hotkeys + preset manager uses mainwindow component list --- freeze.py | 4 +- setup.py | 4 +- src/__init__.py | 1 + src/components/video.py | 4 +- src/core.py | 10 ++-- src/mainwindow.py | 135 +++++++++++++++++++++++++++++------------------- src/presetmanager.py | 23 +++++++-- src/toolkit/ffmpeg.py | 9 +++- 8 files changed, 122 insertions(+), 68 deletions(-) (limited to 'src/mainwindow.py') diff --git a/freeze.py b/freeze.py index 3281cad..520b445 100644 --- a/freeze.py +++ b/freeze.py @@ -2,7 +2,7 @@ from cx_Freeze import setup, Executable import sys import os -from setup import VERSION +from setup import __version__ deps = [os.path.join('src', p) for p in os.listdir('src') if p] @@ -52,7 +52,7 @@ executables = [ setup( name='audio-visualizer-python', - version=VERSION, + 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 5abb976..a2d8495 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.rc1' def package_files(directory): @@ -15,7 +15,7 @@ def package_files(directory): setup( name='audio_visualizer_python', - version=VERSION, + 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', diff --git a/src/__init__.py b/src/__init__.py index e69de29..8b13789 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -0,0 +1 @@ + diff --git a/src/components/video.py b/src/components/video.py index b35c2e5..8758b12 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -16,7 +16,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 Core.FFMPEG_BIN 'videoPath', 'width', 'height', @@ -28,7 +28,7 @@ class Video: ] for arg in mandatoryArgs: try: - exec('self.%s = kwargs[arg]' % arg) + setattr(self, arg, kwargs[arg]) except KeyError: raise BadComponentInit(arg, self.__doc__) diff --git a/src/core.py b/src/core.py index dd2ef18..f6cf5eb 100644 --- a/src/core.py +++ b/src/core.py @@ -15,16 +15,14 @@ import video_thread class Core: ''' MainWindow and Command module both use an instance of this class - 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. + 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. ''' @classmethod def storeSettings(cls): - ''' - Stores settings/paths to directories as class variables - ''' + '''Store settings/paths to directories as class variables.''' if getattr(sys, 'frozen', False): # frozen wd = os.path.dirname(sys.executable) diff --git a/src/mainwindow.py b/src/mainwindow.py index 9944d1a..2d598ae 100644 --- a/src/mainwindow.py +++ b/src/mainwindow.py @@ -178,7 +178,6 @@ class MainWindow(QtWidgets.QMainWindow): # Make component buttons self.compMenu = QMenu() - self.compActions = [] for i, comp in enumerate(self.core.modules): action = self.compMenu.addAction(comp.Component.name) action.triggered.connect( @@ -191,6 +190,9 @@ class MainWindow(QtWidgets.QMainWindow): componentList.itemSelectionChanged.connect( self.changeComponentWidget ) + componentList.itemSelectionChanged.connect( + self.presetManager.clearPresetListSelection + ) self.window.pushButton_removeComponent.clicked.connect( lambda: self.removeComponent() ) @@ -313,22 +315,23 @@ class MainWindow(QtWidgets.QMainWindow): ) self.settings.setValue("ffmpegMsgShown", True) - # Setup Hotkeys + # Hotkeys for projects QtWidgets.QShortcut("Ctrl+S", self.window, self.saveCurrentProject) QtWidgets.QShortcut("Ctrl+A", self.window, self.openSaveProjectDialog) QtWidgets.QShortcut("Ctrl+O", self.window, self.openOpenProjectDialog) QtWidgets.QShortcut("Ctrl+N", self.window, self.createNewProject) - QtWidgets.QShortcut( - "Ctrl+Alt+Shift+R", self.window, self.drawPreview - ) - QtWidgets.QShortcut( - "Ctrl+Alt+Shift+F", self.window, self.showFfmpegCommand - ) - QtWidgets.QShortcut( - "Ctrl+T", self.window, - activated=lambda: self.window.pushButton_addComponent.click() - ) + # Hotkeys for component list + for inskey in ("Ctrl+T", QtCore.Qt.Key_Insert): + QtWidgets.QShortcut( + inskey, self.window, + activated=lambda: self.window.pushButton_addComponent.click() + ) + for delkey in ("Ctrl+R", QtCore.Qt.Key_Delete): + QtWidgets.QShortcut( + delkey, self.window.listWidget_componentList, + self.removeComponent + ) QtWidgets.QShortcut( "Ctrl+Space", self.window, activated=lambda: self.window.listWidget_componentList.setFocus() @@ -342,22 +345,29 @@ class MainWindow(QtWidgets.QMainWindow): ) QtWidgets.QShortcut( - "Ctrl+Up", self.window, + "Ctrl+Up", self.window.listWidget_componentList, activated=lambda: self.moveComponent(-1) ) QtWidgets.QShortcut( - "Ctrl+Down", self.window, + "Ctrl+Down", self.window.listWidget_componentList, activated=lambda: self.moveComponent(1) ) QtWidgets.QShortcut( - "Ctrl+Home", self.window, + "Ctrl+Home", self.window.listWidget_componentList, activated=lambda: self.moveComponent('top') ) QtWidgets.QShortcut( - "Ctrl+End", self.window, + "Ctrl+End", self.window.listWidget_componentList, activated=lambda: self.moveComponent('bottom') ) - QtWidgets.QShortcut("Ctrl+r", self.window, self.removeComponent) + + # Debug Hotkeys + QtWidgets.QShortcut( + "Ctrl+Alt+Shift+R", self.window, self.drawPreview + ) + QtWidgets.QShortcut( + "Ctrl+Alt+Shift+F", self.window, self.showFfmpegCommand + ) @QtCore.pyqtSlot() def cleanUp(self): @@ -677,9 +687,7 @@ class MainWindow(QtWidgets.QMainWindow): stackedWidget.setCurrentIndex(newRow) self.drawPreview() - @disableWhenEncoding - def dragComponent(self, event): - '''Used as Qt drop event for the component listwidget''' + def getComponentListRects(self): componentList = self.window.listWidget_componentList modelIndexes = [ @@ -690,6 +698,13 @@ class MainWindow(QtWidgets.QMainWindow): componentList.visualRect(modelIndex) for modelIndex in modelIndexes ] + return rects + + @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): @@ -826,47 +841,63 @@ class MainWindow(QtWidgets.QMainWindow): @disableWhenEncoding def componentContextMenu(self, QPos): - '''Appears when right-clicking a component in the list''' + '''Appears when right-clicking the component list''' componentList = self.window.listWidget_componentList - if not componentList.selectedItems(): - return - - # don't show menu if clicking empty space - parentPosition = componentList.mapToGlobal(QtCore.QPoint(0, 0)) index = componentList.currentRow() - modelIndex = componentList.model().index(index) - if not componentList.visualRect(modelIndex).contains(QPos): - return - self.presetManager.findPresets() self.menu = QMenu() - menuItem = self.menu.addAction("Save Preset") - menuItem.triggered.connect( - self.presetManager.openSavePresetDialog - ) + parentPosition = componentList.mapToGlobal(QtCore.QPoint(0, 0)) - # submenu for opening presets - try: - presets = self.presetManager.presets[ - str(self.core.selectedComponents[index]) - ] - self.submenu = QMenu("Open Preset") - self.menu.addMenu(self.submenu) - - for version, presetName in presets: - menuItem = self.submenu.addAction(presetName) + 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: + # Show preset menu if clicking a component + self.presetManager.findPresets() + menuItem = self.menu.addAction("Save Preset") + menuItem.triggered.connect( + self.presetManager.openSavePresetDialog + ) + + # submenu for opening presets + try: + presets = self.presetManager.presets[ + str(self.core.selectedComponents[index]) + ] + self.presetSubmenu = QMenu("Open Preset") + self.menu.addMenu(self.presetSubmenu) + + for version, presetName in presets: + menuItem = self.presetSubmenu.addAction(presetName) + menuItem.triggered.connect( + lambda _, presetName=presetName: + self.presetManager.openPreset(presetName) + ) + except KeyError: + pass + + if self.core.selectedComponents[index].currentPreset: + menuItem = self.menu.addAction("Clear Preset") menuItem.triggered.connect( - lambda _, presetName=presetName: - self.presetManager.openPreset(presetName) + self.presetManager.clearPreset ) - except KeyError: - pass + self.menu.addSeparator() - if self.core.selectedComponents[index].currentPreset: - menuItem = self.menu.addAction("Clear Preset") + # "Add Component" submenu + self.submenu = QMenu("Add") + self.menu.addMenu(self.submenu) + for i, comp in enumerate(self.core.modules): + menuItem = self.submenu.addAction(comp.Component.name) menuItem.triggered.connect( - self.presetManager.clearPreset - ) + lambda _, item=i: self.core.insertComponent( + rowPos, item, self + ) + ) self.menu.move(parentPosition + QPos) self.menu.show() diff --git a/src/presetmanager.py b/src/presetmanager.py index 825fdee..64e2203 100644 --- a/src/presetmanager.py +++ b/src/presetmanager.py @@ -245,11 +245,25 @@ class PresetManager(QtWidgets.QDialog): def openRenamePresetDialog(self): # TODO: maintain consistency by changing this to call createNewPreset() presetList = self.window.listWidget_presets - if presetList.currentRow() == -1: - return + index = presetList.currentRow() + 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 not preset: + return + else: + for i, tup in enumerate(self.presetRows): + if preset == tup[2]: + index = i + break + else: + return while True: - index = presetList.currentRow() newName, OK = QtWidgets.QInputDialog.getText( self.window, 'Preset Manager', @@ -321,3 +335,6 @@ class PresetManager(QtWidgets.QDialog): parent=self.window ) self.settings.setValue("presetDir", os.path.dirname(filename)) + + def clearPresetListSelection(self): + self.window.listWidget_presets.setCurrentRow(-1) diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py index 89d4e9d..cc59a6c 100644 --- a/src/toolkit/ffmpeg.py +++ b/src/toolkit/ffmpeg.py @@ -113,7 +113,7 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1): '-t', safeDuration, # Tell ffmpeg about shorter clips (seemingly not needed) # streamDuration = getAudioDuration(extraInputFile) - # if streamDuration > float(safeDuration) + # if streamDuration and streamDuration > float(safeDuration) # else "{0:.3f}".format(streamDuration), '-i', extraInputFile ]) @@ -228,11 +228,18 @@ def getAudioDuration(filename): d = d.split(' ')[3] d = d.split(':') duration = float(d[0])*3600 + float(d[1])*60 + float(d[2]) + break + else: + # String not found in output + return False return duration def readAudioFile(filename, parent): duration = getAudioDuration(filename) + if not duration: + print('Audio file doesn\'t exist or unreadable.') + return command = [ Core.FFMPEG_BIN, -- 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/mainwindow.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/mainwindow.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 d92fc6373fd070f0ea303e9795eb7648d5cd9e90 Mon Sep 17 00:00:00 2001 From: tassaron Date: Sun, 23 Jul 2017 22:55:41 -0400 Subject: ComponentError exception wraps previewRender probably where errors are likeliest to be found --- src/command.py | 6 +++ src/component.py | 119 +++++++++++++++++++++++++++--------------------- src/components/video.py | 6 +-- src/core.py | 3 ++ src/mainwindow.py | 8 ++-- src/toolkit/frame.py | 18 ++++++++ src/video_thread.py | 4 +- 7 files changed, 104 insertions(+), 60 deletions(-) (limited to 'src/mainwindow.py') diff --git a/src/command.py b/src/command.py index ca186e5..74ca821 100644 --- a/src/command.py +++ b/src/command.py @@ -146,6 +146,12 @@ class Command(QtCore.QObject): if 'detail' in kwargs: print(kwargs['detail']) + @QtCore.pyqtSlot(str, str) + def videoThreadError(self, msg, detail): + print(msg) + print(detail) + quit(1) + def drawPreview(self, *args): pass diff --git a/src/component.py b/src/component.py index 8b5f1b8..41cb5eb 100644 --- a/src/component.py +++ b/src/component.py @@ -6,54 +6,64 @@ from PyQt5 import uic, QtCore, QtWidgets import os -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): - 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 - - -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 mutates some attributes for easier use by the core program. E.g., takes only major version from version string & decorates methods ''' + + def renderWrapper(func): + def decorator(self, *args, **kwargs): + try: + return func(self, *args, **kwargs) + except: + from toolkit.frame import BlankFrame + try: + raise ComponentError(self, 'renderer', immediate=True) + except ComponentError: + return BlankFrame() + return decorator + + 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): + 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 + + 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 + def __new__(cls, name, parents, attrs): if 'ui' not in attrs: # Use module name as ui filename by default @@ -62,7 +72,11 @@ class ComponentMetaclass(type(QtCore.QObject)): )[0] # if parents[0] == QtCore.QObject: else: - decorate = ('names', 'error', 'audio', 'command', 'properties') + decorate = ( + 'names', # Class methods + 'error', 'audio', 'properties', # Properties + 'previewRender', 'command', + ) # Auto-decorate methods for key in decorate: @@ -76,13 +90,16 @@ class ComponentMetaclass(type(QtCore.QObject)): attrs[key] = property(attrs[key]) if key == 'command': - attrs[key] = commandWrapper(attrs[key]) + attrs[key] = cls.commandWrapper(attrs[key]) + + if key == 'previewRender': + attrs[key] = cls.renderWrapper(attrs[key]) if key == 'properties': - attrs[key] = propertiesWrapper(attrs[key]) + attrs[key] = cls.propertiesWrapper(attrs[key]) if key == 'error': - attrs[key] = errorWrapper(attrs[key]) + attrs[key] = cls.errorWrapper(attrs[key]) # Turn version string into a number try: @@ -223,11 +240,11 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): if kwarg in ('presetNames', 'commandArgs'): setattr(self, '_%s' % kwarg, kwargs[kwarg]) else: - raise BadComponentInit( + raise ComponentError( self, 'Nonsensical keywords to trackWidgets.', immediate=True) - except BadComponentInit: + except ComponentError: continue def update(self): @@ -366,7 +383,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): ''' -class BadComponentInit(AttributeError): +class ComponentError(RuntimeError): ''' Indicates a Python error in constructing a component. Raising this locks the component into an error state, @@ -397,9 +414,7 @@ class BadComponentInit(AttributeError): ) if immediate: - caller.parent.showMessage( - msg=string, detail=detail, icon='Warning' - ) + caller._error.emit(string, detail) else: caller.lockProperties(['error']) caller.lockError((string, detail)) diff --git a/src/components/video.py b/src/components/video.py index d3696d4..383531e 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -7,7 +7,7 @@ import threading from queue import PriorityQueue from core import Core -from component import Component, BadComponentInit +from component import Component, ComponentError from toolkit.frame import BlankFrame from toolkit.ffmpeg import testAudioStream from toolkit import openPipe, checkOutput @@ -195,14 +195,14 @@ class Component(Component): self.updateChunksize(width, height) try: self.video = Video( - ffmpeg=self.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, component=self, scale=self.scale ) if os.path.exists(self.videoPath) else None except KeyError: - raise BadComponentInit(self, 'Frame Fetcher initialization') + raise ComponentError(self, 'Frame Fetcher initialization') def frameRender(self, layerNo, frameNo): if self.video: diff --git a/src/core.py b/src/core.py index 2f9c36c..4c08c04 100644 --- a/src/core.py +++ b/src/core.py @@ -76,6 +76,9 @@ class Core: component ) self.componentListChanged() + self.selectedComponents[compPos]._error.connect( + loader.videoThreadError + ) # init component's widget for loading/saving presets self.selectedComponents[compPos].widget(loader) diff --git a/src/mainwindow.py b/src/mainwindow.py index a32c1b4..03b8dde 100644 --- a/src/mainwindow.py +++ b/src/mainwindow.py @@ -578,7 +578,11 @@ class MainWindow(QtWidgets.QMainWindow): detail=detail, icon='Warning', ) - self.stopVideo() + try: + self.stopVideo() + except AttributeError as e: + if 'videoWorker' not in str(e): + raise def changeEncodingStatus(self, status): self.encoding = status @@ -684,8 +688,6 @@ 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]) diff --git a/src/toolkit/frame.py b/src/toolkit/frame.py index ca2a054..b66e037 100644 --- a/src/toolkit/frame.py +++ b/src/toolkit/frame.py @@ -41,15 +41,33 @@ class PaintColor(QtGui.QColor): super().__init__(b, g, r, a) +def defaultSize(framefunc): + '''Makes width/height arguments optional''' + def decorator(*args): + if len(args) < 2: + newArgs = list(args) + if len(args) == 0 or len(args) == 1: + height = int(core.Core.settings.value("outputHeight")) + newArgs.append(height) + if len(args) == 0: + width = int(core.Core.settings.value("outputWidth")) + newArgs.insert(0, width) + args = tuple(newArgs) + return framefunc(*args) + return decorator + + def FloodFrame(width, height, RgbaTuple): return Image.new("RGBA", (width, height), RgbaTuple) +@defaultSize def BlankFrame(width, height): '''The base frame used by each component to start drawing.''' return FloodFrame(width, height, (0, 0, 0, 0)) +@defaultSize def Checkerboard(width, height): ''' A checkerboard to represent transparency to the user. diff --git a/src/video_thread.py b/src/video_thread.py index 68eae4f..dd957e5 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -18,7 +18,7 @@ from threading import Thread, Event import time import signal -from component import BadComponentInit +from component import ComponentError from toolkit import openPipe from toolkit.ffmpeg import readAudioFile, createFfmpegCommand from toolkit.frame import Checkerboard @@ -160,7 +160,7 @@ class Worker(QtCore.QObject): progressBarUpdate=self.progressBarUpdate, progressBarSetText=self.progressBarSetText ) - except BadComponentInit: + except ComponentError: pass if 'error' in comp.properties(): -- 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/mainwindow.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 15d70474d4df16cd03f4eb672d409166f793eabf Mon Sep 17 00:00:00 2001 From: tassaron Date: Tue, 25 Jul 2017 22:02:47 -0400 Subject: error can be locked within properties() and simplified the componenterrors again --- src/component.py | 52 ++++++++++++++++-------------------------------- src/components/video.py | 35 ++++++++++++++------------------ src/mainwindow.py | 10 +++++----- src/presetmanager.pyc | Bin 0 -> 10936 bytes src/toolkit/ffmpeg.py | 4 ++-- src/video_thread.py | 11 ++++++---- 6 files changed, 46 insertions(+), 66 deletions(-) create mode 100644 src/presetmanager.pyc (limited to 'src/mainwindow.py') diff --git a/src/component.py b/src/component.py index 7a768ed..5de67d1 100644 --- a/src/component.py +++ b/src/component.py @@ -19,7 +19,7 @@ class ComponentMetaclass(type(QtCore.QObject)): return func(self, *args, **kwargs) except Exception: try: - raise ComponentInitError(self, 'initialization process') + raise ComponentError(self, 'initialization process') except ComponentError: return return initializationWrapper @@ -63,7 +63,13 @@ class ComponentMetaclass(type(QtCore.QObject)): if self._lockedProperties is not None: return self._lockedProperties else: - return func(self) + try: + return func(self) + except Exception: + try: + raise ComponentError(self, 'properties') + except ComponentError: + return [] return propertiesWrapper def errorWrapper(func): @@ -396,18 +402,18 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): ''' -class ComponentException(RuntimeError): - '''A base class for component errors''' +class ComponentError(RuntimeError): + '''Gives the MainWindow a traceback to display, and cancels the export.''' - _prevErrors = [] + prevErrors = [] - def __init__(self, caller, name, immediate): + def __init__(self, caller, name): 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:]: + 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 return @@ -434,28 +440,4 @@ class ComponentException(RuntimeError): ) ) - if immediate: - caller._error.emit(string, detail) - else: - caller.lockProperties(['error']) - caller.lockError((string, detail)) - - -class ComponentError(ComponentException): - ''' - Use for general Python errors caused by a component at any time. - Raising this gives the MainWindow a traceback to display and - cancels any export in progress. - ''' - def __init__(self, caller, name): - ComponentException.__init__(self, caller, name, True) - - -class ComponentInitError(ComponentError): - ''' - Use for Python errors in preFrameRender, while the export is starting. - This will end the video thread in a clean way by locking the component - into an error state so the export definitely won't begin. - ''' - def __init__(self, caller, name): - ComponentException.__init__(self, caller, name, False) + caller._error.emit(string, detail) diff --git a/src/components/video.py b/src/components/video.py index 6b0a04a..8872fbf 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -154,33 +154,28 @@ 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): - return ['error'] + + if not self.videoPath: + self.lockError("There is no video selected.") + elif self.badVideo: + 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())): + self.lockError("Input and output paths match.") if self.useAudio: props.append('audio') - self.testAudioStream() - if self.badAudio: - return ['error'] + if not testAudioStream(self.videoPath) \ + and self.error() is None: + self.lockError( + "Could not identify an audio stream in this video.") return props - def error(self): - if self.badAudio: - return "Could not identify an audio stream in this video." - if not self.videoPath: - return "There is no video selected." - if not os.path.exists(self.videoPath): - return "The video selected does not exist!" - if self.badVideo: - return "The video selected is corrupt!" - - def testAudioStream(self): - self.badAudio = testAudioStream(self.videoPath) - def audio(self): params = {} if self.volume != 1.0: diff --git a/src/mainwindow.py b/src/mainwindow.py index 3cc5d26..e478d19 100644 --- a/src/mainwindow.py +++ b/src/mainwindow.py @@ -573,16 +573,16 @@ class MainWindow(QtWidgets.QMainWindow): @QtCore.pyqtSlot(str, str) def videoThreadError(self, msg, detail): - self.showMessage( - msg=msg, - detail=detail, - icon='Warning', - ) try: self.stopVideo() except AttributeError as e: if 'videoWorker' not in str(e): raise + self.showMessage( + msg=msg, + detail=detail, + icon='Warning', + ) def changeEncodingStatus(self, status): self.encoding = status diff --git a/src/presetmanager.pyc b/src/presetmanager.pyc new file mode 100644 index 0000000..97069d2 Binary files /dev/null and b/src/presetmanager.pyc differ diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py index 8f5ae87..8d63659 100644 --- a/src/toolkit/ffmpeg.py +++ b/src/toolkit/ffmpeg.py @@ -224,9 +224,9 @@ def testAudioStream(filename): try: checkOutput(audioTestCommand, stderr=subprocess.DEVNULL) except subprocess.CalledProcessError: - return True - else: return False + else: + return True def getAudioDuration(filename): diff --git a/src/video_thread.py b/src/video_thread.py index 8cbe8a8..48f3729 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -163,24 +163,27 @@ class Worker(QtCore.QObject): except ComponentError: pass - if 'error' in comp.properties(): + compProps = comp.properties() + if 'error' in compProps or comp.error() is not None: self.cancel() self.canceled = True canceledByComponent = True compError = comp.error() \ if type(comp.error()) is tuple else (comp.error(), '') errMsg = ( - "Component #%s encountered an error!" % compNo + "Component #%s (%s) encountered an error!" % ( + str(compNo), comp.name + ) if comp.error() is None else 'Export cancelled by component #%s (%s): %s' % ( str(compNo), - str(comp), + comp.name, compError[0] ) ) comp._error.emit(errMsg, compError[1]) break - if 'static' in comp.properties(): + if 'static' in compProps: self.staticComponents[compNo] = \ comp.frameRender(compNo, 0).copy() -- 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/mainwindow.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