diff options
| author | tassaron | 2026-01-11 14:29:58 -0500 |
|---|---|---|
| committer | tassaron | 2026-01-11 14:29:58 -0500 |
| commit | 669756b391d26661cf2e2a97a304e73343ef6655 (patch) | |
| tree | 9cf2d4858c209bdab9f44d5c7f95c2a30b37f7a6 /src | |
| parent | 9d45f7f1a986aaa5d3c084c7ae747442b94a61b1 (diff) | |
update to Qt 6 and Pillow 12
and yeah, I accidentally ran black on the codebase. I don't want to spend more free time fixing that. All of these changes are simple renames or removals, nothing too major.
Diffstat (limited to 'src')
| -rw-r--r-- | src/__init__.py | 27 | ||||
| -rw-r--r-- | src/__main__.py | 37 | ||||
| -rw-r--r-- | src/command.py | 152 | ||||
| -rw-r--r-- | src/component.py | 657 | ||||
| -rw-r--r-- | src/components/color.py | 136 | ||||
| -rw-r--r-- | src/components/image.py | 71 | ||||
| -rw-r--r-- | src/components/life.py | 276 | ||||
| -rw-r--r-- | src/components/life.ui | 2 | ||||
| -rw-r--r-- | src/components/original.py | 189 | ||||
| -rw-r--r-- | src/components/sound.py | 54 | ||||
| -rw-r--r-- | src/components/spectrum.py | 300 | ||||
| -rw-r--r-- | src/components/text.py | 148 | ||||
| -rw-r--r-- | src/components/video.py | 178 | ||||
| -rw-r--r-- | src/components/waveform.py | 170 | ||||
| -rw-r--r-- | src/core.py | 420 | ||||
| -rw-r--r-- | src/gui/actions.py | 69 | ||||
| -rw-r--r-- | src/gui/mainwindow.py | 648 | ||||
| -rw-r--r-- | src/gui/presetmanager.py | 157 | ||||
| -rw-r--r-- | src/gui/preview_thread.py | 50 | ||||
| -rw-r--r-- | src/gui/preview_win.py | 45 | ||||
| -rw-r--r-- | src/tests/__init__.py | 14 | ||||
| -rw-r--r-- | src/tests/test_commandline_export.py | 17 | ||||
| -rw-r--r-- | src/tests/test_commandline_parser.py | 11 | ||||
| -rw-r--r-- | src/tests/test_core_init.py | 16 | ||||
| -rw-r--r-- | src/toolkit/common.py | 87 | ||||
| -rw-r--r-- | src/toolkit/ffmpeg.py | 455 | ||||
| -rw-r--r-- | src/toolkit/frame.py | 69 | ||||
| -rw-r--r-- | src/video_thread.py | 182 |
28 files changed, 2469 insertions, 2168 deletions
diff --git a/src/__init__.py b/src/__init__.py index 2080de5..ee9bebb 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -3,20 +3,21 @@ import os import logging -__version__ = '2.0.0' +__version__ = "2.1.0" class Logger(logging.getLoggerClass()): - ''' - Custom Logger class to handle custom VERBOSE log level. - Levels used in this program are as follows: - VERBOSE Annoyingly frequent debug messages (e.g, in loops) - DEBUG Ordinary debug information - INFO Expected events that are expensive or irreversible - WARNING A non-fatal error or suspicious behaviour - ERROR Any error that would interrupt the user - CRITICAL Things that really shouldn't happen at all - ''' + """ + Custom Logger class to handle custom VERBOSE log level. + Levels used in this program are as follows: + VERBOSE Annoyingly frequent debug messages (e.g, in loops) + DEBUG Ordinary debug information + INFO Expected events that are expensive or irreversible + WARNING A non-fatal error or suspicious behaviour + ERROR Any error that would interrupt the user + CRITICAL Things that really shouldn't happen at all + """ + def __init__(self, name, level=logging.NOTSET): super().__init__(name, level) logging.addLevelName(5, "VERBOSE") @@ -24,11 +25,13 @@ class Logger(logging.getLoggerClass()): def verbose(self, msg, *args, **kwargs): if self.isEnabledFor(5): self._log(5, msg, args, **kwargs) + + logging.setLoggerClass(Logger) logging.VERBOSE = 5 -if getattr(sys, 'frozen', False): +if getattr(sys, "frozen", False): # frozen wd = os.path.dirname(sys.executable) else: diff --git a/src/__main__.py b/src/__main__.py index 284fd2c..db48788 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -1,30 +1,30 @@ -from PyQt5.QtWidgets import QApplication +from PyQt6.QtWidgets import QApplication import sys import logging import re import string -log = logging.getLogger('AVP.Main') +log = logging.getLogger("AVP.Main") def main() -> int: """Returns an exit code (0 for success)""" proj = None - mode = 'GUI' + mode = "GUI" # Determine whether we're in GUI or commandline mode if len(sys.argv) > 2: - mode = 'commandline' + mode = "commandline" elif len(sys.argv) == 2: - if sys.argv[1].startswith('-'): - mode = 'commandline' + if sys.argv[1].startswith("-"): + mode = "commandline" else: # remove unsafe punctuation characters such as \/?*&^%$# - if sys.argv[1].endswith('.avp'): + if sys.argv[1].endswith(".avp"): # remove file extension sys.argv[1] = sys.argv[1][:-4] - sys.argv[1] = re.sub(f'[{re.escape(string.punctuation)}]', '', sys.argv[1]) + sys.argv[1] = re.sub(f"[{re.escape(string.punctuation)}]", "", sys.argv[1]) # opening a project file with gui proj = sys.argv[1] @@ -32,8 +32,16 @@ def main() -> int: app = QApplication(sys.argv) app.setApplicationName("audio-visualizer") + screen = app.primaryScreen() + if screen is None: + dpi = None + log.error("Could not detect DPI") + else: + dpi = screen.physicalDotsPerInchX() + log.info("Detected screen DPI: %s", dpi) + # Launch program - if mode == 'commandline': + if mode == "commandline": from .command import Command main = Command() @@ -42,14 +50,15 @@ def main() -> int: # Both branches here may occur in one execution: # Commandline parsing could change mode back to GUI - if mode == 'GUI': + if mode == "GUI": from .gui.mainwindow import MainWindow - mainWindow = MainWindow(proj) + mainWindow = MainWindow(proj, dpi) log.debug("Finished creating MainWindow") mainWindow.raise_() - return app.exec_() + return app.exec() + -if __name__ == '__main__': - sys.exit(main())
\ No newline at end of file +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/command.py b/src/command.py index 53801fa..783ac26 100644 --- a/src/command.py +++ b/src/command.py @@ -1,9 +1,10 @@ -''' - When using commandline mode, this module's object handles interpreting - the arguments and giving them to Core, which tracks the main program state. - Then it immediately exports a video. -''' -from PyQt5 import QtCore +""" +When using commandline mode, this module's object handles interpreting +the arguments and giving them to Core, which tracks the main program state. +Then it immediately exports a video. +""" + +from PyQt6 import QtCore import argparse import os import sys @@ -16,12 +17,12 @@ import logging from . import core -log = logging.getLogger('AVP.Commandline') +log = logging.getLogger("AVP.Commandline") class Command(QtCore.QObject): """ - This replaces the GUI MainWindow when in commandline mode. + This replaces the GUI MainWindow when in commandline mode. """ createVideo = QtCore.pyqtSignal() @@ -29,7 +30,7 @@ class Command(QtCore.QObject): def __init__(self): super().__init__() self.core = core.Core() - core.Core.mode = 'commandline' + core.Core.mode = "commandline" self.dataDir = self.core.dataDir self.canceled = False self.settings = core.Core.settings @@ -39,52 +40,58 @@ class Command(QtCore.QObject): def parseArgs(self): parser = argparse.ArgumentParser( - prog='avp' if os.path.basename(sys.argv[0]) == "__main__.py" else None, - description='Create a visualization for an audio file', - epilog='EXAMPLE COMMAND: avp myvideotemplate ' - '-i ~/Music/song.mp3 -o ~/video.mp4 ' - '-c 0 image path=~/Pictures/thisWeeksPicture.jpg ' - '-c 1 video "preset=My Logo" -c 2 vis layout=classic' + prog="avp" if os.path.basename(sys.argv[0]) == "__main__.py" else None, + description="Create a visualization for an audio file", + epilog="EXAMPLE COMMAND: avp myvideotemplate " + "-i ~/Music/song.mp3 -o ~/video.mp4 " + "-c 0 image path=~/Pictures/thisWeeksPicture.jpg " + '-c 1 video "preset=My Logo" -c 2 vis layout=classic', ) - # input/output automatic-export commands + parser.add_argument("-i", "--input", metavar="SOUND", help="input audio file") parser.add_argument( - '-i', '--input', metavar='SOUND', - help='input audio file' + "-o", "--output", metavar="OUTPUT", help="output video file" ) parser.add_argument( - '-o', '--output', metavar='OUTPUT', - help='output video file' - ) - parser.add_argument( - '--export-project', action='store_true', - help='use input and output files from project file if -i or -o is missing' + "--export-project", + action="store_true", + help="use input and output files from project file if -i or -o is missing", ) # mutually exclusive debug options debugCommands = parser.add_mutually_exclusive_group() debugCommands.add_argument( - '--test', action='store_true', - help='run tests and create a report full of debugging info' + "--test", + action="store_true", + help="run tests and create a report full of debugging info", ) debugCommands.add_argument( - '--debug', action='store_true', - help='create bigger logfiles while program is running' + "--debug", + action="store_true", + help="create bigger logfiles while program is running", ) # project/GUI options parser.add_argument( - 'projpath', metavar='path-to-project', - help='open a project file (.avp)', nargs='?') + "projpath", + metavar="path-to-project", + help="open a project file (.avp)", + nargs="?", + ) parser.add_argument( - '-c', '--comp', metavar=('LAYER', 'ARG'), - help='first arg must be component NAME to insert at LAYER.' + "-c", + "--comp", + metavar=("LAYER", "ARG"), + help="first arg must be component NAME to insert at LAYER." '"help" for information about possible args for a component.', - nargs='*', action='append') + nargs="*", + action="append", + ) parser.add_argument( - '--no-preview', action='store_true', - help='disable live preview during export' + "--no-preview", + action="store_true", + help="disable live preview during export", ) args = parser.parse_args() @@ -101,15 +108,11 @@ class Command(QtCore.QObject): if args.projpath: projPath = args.projpath if not os.path.dirname(projPath): - projPath = os.path.join( - self.settings.value("projectDir"), - projPath - ) - if not projPath.endswith('.avp'): - projPath += '.avp' + projPath = os.path.join(self.settings.value("projectDir"), projPath) + if not projPath.endswith(".avp"): + projPath += ".avp" self.core.openProject(self, projPath) - self.core.selectedComponents = list( - reversed(self.core.selectedComponents)) + self.core.selectedComponents = list(reversed(self.core.selectedComponents)) self.core.componentListChanged() if args.comp: @@ -120,14 +123,17 @@ class Command(QtCore.QObject): try: pos = int(pos) except ValueError: - print(pos, 'is not a layer number.') + print(pos, "is not a layer number.") quit(1) realName = self.parseCompName(name) if not realName: - print(name, 'is not a valid component name.') + print(name, "is not a valid component name.") quit(1) modI = self.core.moduleIndexFor(realName) i = self.core.insertComponent(pos, modI, self) + if i is None: + print(name, "could not be initialized.") + quit(1) for arg in compargs: self.core.selectedComponents[i].command(arg) @@ -135,15 +141,12 @@ class Command(QtCore.QObject): errcode, data = self.core.parseAvFile(projPath) input_ = None output = None - for key, value in data['WindowFields']: - if 'outputFile' in key: + for key, value in data["WindowFields"]: + if "outputFile" in key: output = value if output and not os.path.dirname(value): - output = os.path.join( - os.path.expanduser('~'), - output - ) - if 'audioFile' in key: + output = os.path.join(os.path.expanduser("~"), output) + if "audioFile" in key: input_ = value # use input/output from project file, overwritten by -i and -o @@ -153,7 +156,7 @@ class Command(QtCore.QObject): self.createAudioVisualization( input_ if not args.input else args.input, - output if not args.output else args.output + output if not args.output else args.output, ) return "commandline" @@ -165,11 +168,11 @@ class Command(QtCore.QObject): core.Core.previewEnabled = False elif ( - args.projpath is None and - 'help' not in sys.argv and - '--debug' not in sys.argv and - '--test' not in sys.argv - ): + args.projpath is None + and "help" not in sys.argv + and "--debug" not in sys.argv + and "--test" not in sys.argv + ): parser.print_help() quit(1) @@ -180,12 +183,9 @@ class Command(QtCore.QObject): print("No components selected. Adding a default visualizer.") time.sleep(1) self.core.insertComponent(0, 0, self) - self.core.selectedComponents = list( - reversed(self.core.selectedComponents)) + self.core.selectedComponents = list(reversed(self.core.selectedComponents)) self.core.componentListChanged() - self.worker = self.core.newVideoWorker( - self, input, output - ) + self.worker = self.core.newVideoWorker(self, input, output) # quit(0) after video is created self.worker.videoCreated.connect(self.videoCreated) self.lastProgressUpdate = time.time() @@ -199,16 +199,18 @@ class Command(QtCore.QObject): @QtCore.pyqtSlot(str) def progressBarSetText(self, value): - if 'Export ' in value: + if "Export " in value: # Don't duplicate completion/failure messages return - if not value.startswith('Exporting') \ - and time.time() - self.lastProgressUpdate >= 0.05: + if ( + not value.startswith("Exporting") + and time.time() - self.lastProgressUpdate >= 0.05 + ): # Show most messages very often print(value) elif time.time() - self.lastProgressUpdate >= 2.0: # Give user time to read ffmpeg's output during the export - print('##### %s' % value) + print("##### %s" % value) else: return self.lastProgressUpdate = time.time() @@ -221,9 +223,9 @@ class Command(QtCore.QObject): quit(code) def showMessage(self, **kwargs): - print(kwargs['msg']) - if 'detail' in kwargs: - print(kwargs['detail']) + print(kwargs["msg"]) + if "detail" in kwargs: + print(kwargs["detail"]) @QtCore.pyqtSlot(str, str) def videoThreadError(self, msg, detail): @@ -235,7 +237,7 @@ class Command(QtCore.QObject): pass def parseCompName(self, name): - '''Deduces a proper component name out of a commandline arg''' + """Deduces a proper component name out of a commandline arg""" if name.title() in self.core.compNames: return name.title() @@ -244,9 +246,7 @@ class Command(QtCore.QObject): return compName compFileNames = [ - os.path.splitext( - os.path.basename(mod.__file__) - )[0] + os.path.splitext(os.path.basename(mod.__file__))[0] for mod in self.core.modules ] for i, compFileName in enumerate(compFileNames): @@ -258,15 +258,17 @@ class Command(QtCore.QObject): def runTests(self): from . import tests + test_report = os.path.join(core.Core.logDir, "test_report.log") tests.run(test_report) # Choose a numbered location to put the output file logNumber = 0 + def getFilename(): """Get a numbered filename for the final test report""" nonlocal logNumber - name = os.path.join(os.path.expanduser('~'), "avp_test_report") + name = os.path.join(os.path.expanduser("~"), "avp_test_report") while True: possibleName = f"{name}{logNumber:0>2}.txt" if os.path.exists(possibleName) and logNumber < 100: diff --git a/src/component.py b/src/component.py index 1e10f66..01d4e44 100644 --- a/src/component.py +++ b/src/component.py @@ -1,9 +1,10 @@ -''' - Base classes for components to import. Read comments for some documentation - on making a valid component. -''' -from PyQt5 import uic, QtCore, QtWidgets -from PyQt5.QtGui import QColor +""" +Base classes for components to import. Read comments for some documentation +on making a valid component. +""" + +from PyQt6 import uic, QtCore, QtWidgets +from PyQt6.QtGui import QColor, QUndoCommand import os import sys import math @@ -13,18 +14,22 @@ from copy import copy from .toolkit.frame import BlankFrame from .toolkit import ( - getWidgetValue, setWidgetValue, connectWidget, rgbFromString, blockSignals + getWidgetValue, + setWidgetValue, + connectWidget, + rgbFromString, + blockSignals, ) -log = logging.getLogger('AVP.ComponentHandler') +log = logging.getLogger("AVP.ComponentHandler") class ComponentMetaclass(type(QtCore.QObject)): - ''' - Checks the validity of each Component class and mutates some attrs. - E.g., takes only major version from version string & decorates methods - ''' + """ + Checks the validity of each Component class and mutates some attrs. + E.g., takes only major version from version string & decorates methods + """ def initializationWrapper(func): def initializationWrapper(self, *args, **kwargs): @@ -32,51 +37,55 @@ class ComponentMetaclass(type(QtCore.QObject)): return func(self, *args, **kwargs) except Exception: try: - raise ComponentError(self, 'initialization process') + raise ComponentError(self, "initialization process") except ComponentError: return + return initializationWrapper def renderWrapper(func): def renderWrapper(self, *args, **kwargs): try: log.verbose( - '### %s #%s renders a preview frame ###', - self.__class__.name, str(self.compPos), + "### %s #%s renders a preview frame ###", + self.__class__.name, + str(self.compPos), ) return func(self, *args, **kwargs) except Exception as e: try: - if e.__class__.__name__.startswith('Component'): + if e.__class__.__name__.startswith("Component"): raise else: - raise ComponentError(self, 'renderer') + raise ComponentError(self, "renderer") except ComponentError: return BlankFrame() + return renderWrapper def commandWrapper(func): - '''Intercepts the command() method to check for global args''' + """Intercepts the command() method to check for global args""" + def commandWrapper(self, arg): - if arg.startswith('preset='): - _, preset = arg.split('=', 1) + if arg.startswith("preset="): + _, preset = arg.split("=", 1) path = os.path.join(self.core.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) - ) + 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 commandWrapper def propertiesWrapper(func): - '''Intercepts the usual properties if the properties are locked.''' + """Intercepts the usual properties if the properties are locked.""" + def propertiesWrapper(self): if self._lockedProperties is not None: return self._lockedProperties @@ -85,22 +94,26 @@ class ComponentMetaclass(type(QtCore.QObject)): return func(self) except Exception: try: - raise ComponentError(self, 'properties') + raise ComponentError(self, "properties") except ComponentError: return [] + return propertiesWrapper def errorWrapper(func): - '''Intercepts the usual error message if it is locked.''' + """Intercepts the usual error message if it is locked.""" + def errorWrapper(self): if self._lockedError is not None: return self._lockedError else: return func(self) + return errorWrapper def loadPresetWrapper(func): - '''Wraps loadPreset to handle the self.openingPreset boolean''' + """Wraps loadPreset to handle the self.openingPreset boolean""" + class openingPreset: def __init__(self, comp): self.comp = comp @@ -117,17 +130,19 @@ class ComponentMetaclass(type(QtCore.QObject)): return func(self, *args) except Exception: try: - raise ComponentError(self, 'preset loader') + raise ComponentError(self, "preset loader") except ComponentError: return + return presetWrapper def updateWrapper(func): - ''' - Calls _preUpdate before every subclass update(). - Afterwards, for non-user updates, calls _autoUpdate(). - For undoable updates triggered by the user, calls _userUpdate() - ''' + """ + Calls _preUpdate before every subclass update(). + Afterwards, for non-user updates, calls _autoUpdate(). + For undoable updates triggered by the user, calls _userUpdate() + """ + class wrap: def __init__(self, comp, auto): self.comp = comp @@ -137,28 +152,33 @@ class ComponentMetaclass(type(QtCore.QObject)): self.comp._preUpdate() def __exit__(self, *args): - if self.auto or self.comp.openingPreset \ - or not hasattr(self.comp.parent, 'undoStack'): - log.verbose('Automatic update') + if ( + self.auto + or self.comp.openingPreset + or not hasattr(self.comp.parent, "undoStack") + ): + log.verbose("Automatic update") self.comp._autoUpdate() else: - log.verbose('User update') + log.verbose("User update") self.comp._userUpdate() def updateWrapper(self, **kwargs): - auto = kwargs['auto'] if 'auto' in kwargs else False + auto = kwargs["auto"] if "auto" in kwargs else False with wrap(self, auto): try: return func(self) except Exception: try: - raise ComponentError(self, 'update method') + raise ComponentError(self, "update method") except ComponentError: return + return updateWrapper def widgetWrapper(func): - '''Connects all widgets to update method after the subclass's method''' + """Connects all widgets to update method after the subclass's method""" + class wrap: def __init__(self, comp): self.comp = comp @@ -169,92 +189,99 @@ class ComponentMetaclass(type(QtCore.QObject)): def __exit__(self, *args): for widgetList in self.comp._allWidgets.values(): for widget in widgetList: - log.verbose('Connecting %s', str( - widget.__class__.__name__)) + log.verbose("Connecting %s", str(widget.__class__.__name__)) connectWidget(widget, self.comp.update) def widgetWrapper(self, *args, **kwargs): - auto = kwargs['auto'] if 'auto' in kwargs else False + auto = kwargs["auto"] if "auto" in kwargs else False with wrap(self): try: return func(self, *args, **kwargs) except Exception: try: - raise ComponentError(self, 'widget creation') + raise ComponentError(self, "widget creation") except ComponentError: return + return widgetWrapper def __new__(cls, name, parents, attrs): - if 'ui' not in attrs: + 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] + attrs["ui"] = ( + "%s.ui" % os.path.splitext(attrs["__module__"].split(".")[-1])[0] + ) decorate = ( - 'names', # Class methods - 'error', 'audio', 'properties', # Properties - 'preFrameRender', 'previewRender', - 'loadPreset', 'command', - 'update', 'widget', + "names", # Class methods + "error", + "audio", + "properties", # Properties + "preFrameRender", + "previewRender", + "loadPreset", + "command", + "update", + "widget", ) # Auto-decorate methods for key in decorate: if key not in attrs: continue - if key in ('names'): + if key in ("names"): attrs[key] = classmethod(attrs[key]) - elif key in ('audio'): + elif key in ("audio"): attrs[key] = property(attrs[key]) - elif key == 'command': + elif key == "command": attrs[key] = cls.commandWrapper(attrs[key]) - elif key == 'previewRender': + elif key == "previewRender": attrs[key] = cls.renderWrapper(attrs[key]) - elif key == 'preFrameRender': + elif key == "preFrameRender": attrs[key] = cls.initializationWrapper(attrs[key]) - elif key == 'properties': + elif key == "properties": attrs[key] = cls.propertiesWrapper(attrs[key]) - elif key == 'error': + elif key == "error": attrs[key] = cls.errorWrapper(attrs[key]) - elif key == 'loadPreset': + elif key == "loadPreset": attrs[key] = cls.loadPresetWrapper(attrs[key]) - elif key == 'update': + elif key == "update": attrs[key] = cls.updateWrapper(attrs[key]) - elif key == 'widget' and parents[0] != QtCore.QObject: + elif key == "widget" and parents[0] != QtCore.QObject: attrs[key] = cls.widgetWrapper(attrs[key]) # Turn version string into a number try: - if 'version' not in attrs: + if "version" not in attrs: log.error( - 'No version attribute in %s. Defaulting to 1', - attrs['name']) - attrs['version'] = 1 + "No version attribute in %s. Defaulting to 1", + attrs["name"], + ) + attrs["version"] = 1 else: - attrs['version'] = int(attrs['version'].split('.')[0]) + attrs["version"] = int(attrs["version"].split(".")[0]) except ValueError: log.critical( - '%s component has an invalid version string:\n%s', - attrs['name'], str(attrs['version']) + "%s component has an invalid version string:\n%s", + attrs["name"], + str(attrs["version"]), ) except KeyError: - log.critical('%s component has no version string.', attrs['name']) + log.critical("%s component has no version string.", attrs["name"]) else: return super().__new__(cls, name, parents, attrs) quit(1) class Component(QtCore.QObject, metaclass=ComponentMetaclass): - ''' - The base class for components to inherit. - ''' + """ + The base class for components to inherit. + """ - name = 'Component' + name = "Component" # ui = 'name_Of_Non_Default_Ui_File' - version = '1.0.0' + 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. @@ -297,19 +324,19 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): def __repr__(self): import pprint + try: preset = self.savePreset() except Exception as e: - preset = '%s occurred while saving preset' % str(e) - - return ( - 'Component(module %s, pos %s) (%s)\n' - 'Name: %s v%s\nPreset: %s' % ( - self.moduleIndex, self.compPos, - object.__repr__(self), - self.__class__.name, str(self.__class__.version), - pprint.pformat(preset) - ) + preset = "%s occurred while saving preset" % str(e) + + return "Component(module %s, pos %s) (%s)\n" "Name: %s v%s\nPreset: %s" % ( + self.moduleIndex, + self.compPos, + object.__repr__(self), + self.__class__.name, + str(self.__class__.version), + pprint.pformat(preset), ) # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ @@ -321,17 +348,17 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): return image def preFrameRender(self, **kwargs): - ''' - Must call super() when subclassing - Triggered only before a video is exported (video_thread.py) - self.audioFile = filepath to the main input audio file - self.completeAudioArray = a list of audio samples - self.sampleSize = number of audio samples per video frame - self.progressBarUpdate = signal to set progress bar number - self.progressBarSetText = signal to set progress bar text - Use the latter two signals to update the MainWindow if needed - for a long initialization procedure (i.e., for a visualizer) - ''' + """ + Must call super() when subclassing + Triggered only before a video is exported (video_thread.py) + self.audioFile = filepath to the main input audio file + self.completeAudioArray = a list of audio samples + self.sampleSize = number of audio samples per video frame + self.progressBarUpdate = signal to set progress bar number + self.progressBarSetText = signal to set progress bar text + Use the latter two signals to update the MainWindow if needed + for a long initialization procedure (i.e., for a visualizer) + """ for key, value in kwargs.items(): setattr(self, key, value) @@ -348,92 +375,94 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ def properties(self): - ''' - Return a list of properties to signify if your component is - non-animated ('static'), returns sound ('audio'), or has - encountered an error in configuration ('error'). - ''' + """ + Return a list of properties to signify if your component is + non-animated ('static'), returns sound ('audio'), or has + encountered an error in configuration ('error'). + """ return [] def error(self): - ''' - Return a string containing an error message, or None for a default. - Or tuple of two strings for a message with details. - Alternatively use lockError(msgString) within properties() - to skip this method entirely. - ''' + """ + Return a string containing an error message, or None for a default. + Or tuple of two strings for a message with details. + Alternatively use lockError(msgString) within properties() + to skip this method entirely. + """ return def audio(self): - ''' - Return audio to mix into master as a tuple with two elements: - The first element can be: - - A string (path to audio file), - - Or an object that returns audio data through a pipe - The second element must be a dictionary of ffmpeg filters/options - to apply to the input stream. See the filter docs for ideas: - https://ffmpeg.org/ffmpeg-filters.html - ''' + """ + Return audio to mix into master as a tuple with two elements: + The first element can be: + - A string (path to audio file), + - Or an object that returns audio data through a pipe + The second element must be a dictionary of ffmpeg filters/options + to apply to the input stream. See the filter docs for ideas: + https://ffmpeg.org/ffmpeg-filters.html + """ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ # Idle Methods # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 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) and initialize - ''' + """ + 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) and initialize + """ self.parent = parent self.settings = parent.settings log.verbose( - 'Creating UI for %s #%s\'s widget', - self.__class__.name, self.compPos + "Creating UI for %s #%s's widget", + self.__class__.name, + self.compPos, ) self.page = self.loadUi(self.__class__.ui) # Find all normal widgets which will be connected after subclass method self._allWidgets = { - 'lineEdit': self.page.findChildren(QtWidgets.QLineEdit), - 'checkBox': self.page.findChildren(QtWidgets.QCheckBox), - 'spinBox': self.page.findChildren(QtWidgets.QSpinBox), - 'comboBox': self.page.findChildren(QtWidgets.QComboBox), + "lineEdit": self.page.findChildren(QtWidgets.QLineEdit), + "checkBox": self.page.findChildren(QtWidgets.QCheckBox), + "spinBox": self.page.findChildren(QtWidgets.QSpinBox), + "comboBox": self.page.findChildren(QtWidgets.QComboBox), } - self._allWidgets['spinBox'].extend( + self._allWidgets["spinBox"].extend( self.page.findChildren(QtWidgets.QDoubleSpinBox) ) def update(self): - ''' - Starting point for a component update. A subclass should override - this method, and the base class will then magically insert a call - to either _autoUpdate() or _userUpdate() at the end. - ''' + """ + Starting point for a component update. A subclass should override + this method, and the base class will then magically insert a call + to either _autoUpdate() or _userUpdate() at the end. + """ 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'] + """ + 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(): - key = attr if attr not in self._presetNames \ - else self._presetNames[attr] + key = attr if attr not in self._presetNames else self._presetNames[attr] try: val = presetDict[key] except KeyError as e: log.info( - '%s missing value %s. Outdated preset?', - self.currentPreset, str(e) + "%s missing value %s. Outdated preset?", + self.currentPreset, + str(e), ) val = getattr(self, key) if attr in self._colorWidgets: - widget.setText('%s,%s,%s' % val) + widget.setText("%s,%s,%s" % val) btnStyle = ( "QPushButton { background-color : %s; outline: none; }" % QColor(*val).name() @@ -450,8 +479,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): saveValueStore = {} for attr, widget in self._trackedWidgets.items(): presetAttrName = ( - attr if attr not in self._presetNames - else self._presetNames[attr] + attr if attr not in self._presetNames else self._presetNames[attr] ) if attr in self._relativeWidgets: try: @@ -465,19 +493,18 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): return saveValueStore def commandHelp(self): - '''Help text as string for this component's commandline arguments''' - - def command(self, arg=''): - ''' - 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. - ''' + """Help text as string for this component's commandline arguments""" + + def command(self, arg=""): + """ + 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. + """ print( - self.__class__.name, 'Usage:\n' - 'Open a preset for this component:\n' - ' "preset=Preset Name"' + self.__class__.name, + "Usage:\n" "Open a preset for this component:\n" ' "preset=Preset Name"', ) self.commandHelp() quit(0) @@ -486,19 +513,21 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): # "Private" Methods # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ def _preUpdate(self): - '''Happens before subclass update()''' + """Happens before subclass update()""" for attr in self._relativeWidgets: self.updateRelativeWidget(attr) def _userUpdate(self): - '''Happens after subclass update() for an undoable update by user.''' + """Happens after subclass update() for an undoable update by user.""" oldWidgetVals = { - attr: copy(getattr(self, attr)) - for attr in self._trackedWidgets + attr: copy(getattr(self, attr)) for attr in self._trackedWidgets } newWidgetVals = { - attr: getWidgetValue(widget) - if attr not in self._colorWidgets else rgbFromString(widget.text()) + attr: ( + getWidgetValue(widget) + if attr not in self._colorWidgets + else rgbFromString(widget.text()) + ) for attr, widget in self._trackedWidgets.items() } modifiedWidgets = { @@ -511,7 +540,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): self.parent.undoStack.push(action) def _autoUpdate(self): - '''Happens after subclass update() for an internal component update.''' + """Happens after subclass update() for an internal component update.""" newWidgetVals = { attr: getWidgetValue(widget) for attr, widget in self._trackedWidgets.items() @@ -520,10 +549,10 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): self._sendUpdateSignal() def setAttrs(self, attrDict): - ''' - Sets attrs (linked to trackedWidgets) in this component to - the values in the attrDict. Mutates certain widget values if needed - ''' + """ + Sets attrs (linked to trackedWidgets) in this component to + the values in the attrDict. Mutates certain widget values if needed + """ for attr, val in attrDict.items(): if attr in self._colorWidgets: # Color Widgets must have a tuple & have a button to update @@ -533,110 +562,111 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): rgbTuple = rgbFromString(val) btnStyle = ( "QPushButton { background-color : %s; outline: none; }" - % QColor(*rgbTuple).name()) + % QColor(*rgbTuple).name() + ) self._colorWidgets[attr].setStyleSheet(btnStyle) setattr(self, attr, rgbTuple) else: # Normal tracked widget setattr(self, attr, val) - log.verbose('Setting %s self.%s to %s' % ( - self.__class__.name, attr, val)) + log.verbose("Setting %s self.%s to %s" % (self.__class__.name, attr, val)) def setWidgetValues(self, attrDict): - ''' - Sets widgets defined by keys in trackedWidgets in this preset to - the values in the attrDict. - ''' - affectedWidgets = [ - self._trackedWidgets[attr] for attr in attrDict - ] + """ + Sets widgets defined by keys in trackedWidgets in this preset to + the values in the attrDict. + """ + affectedWidgets = [self._trackedWidgets[attr] for attr in attrDict] with blockSignals(affectedWidgets): for attr, val in attrDict.items(): widget = self._trackedWidgets[attr] if attr in self._colorWidgets: - val = '%s,%s,%s' % val + val = "%s,%s,%s" % val setWidgetValue(widget, val) def _sendUpdateSignal(self): if not self.core.openingProject: self.parent.drawPreview() saveValueStore = self.savePreset() - saveValueStore['preset'] = self.currentPreset + saveValueStore["preset"] = self.currentPreset self.modified.emit(self.compPos, saveValueStore) def trackWidgets(self, trackDict, **kwargs): - ''' - Name widgets to track in update(), savePreset(), loadPreset(), and - command(). Requires a dict of attr names as keys, widgets as values - - Optional args: - 'presetNames': preset variable names to replace attr names - 'commandArgs': arg keywords that differ from attr names - 'colorWidgets': identify attr as RGB tuple & update button CSS - 'relativeWidgets': change value proportionally to resolution - - NOTE: Any kwarg key set to None will selectively disable tracking. - ''' + """ + 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 + 'colorWidgets': identify attr as RGB tuple & update button CSS + 'relativeWidgets': change value proportionally to resolution + + NOTE: Any kwarg key set to None will selectively disable tracking. + """ self._trackedWidgets = trackDict for kwarg in kwargs: try: if kwarg in ( - 'presetNames', - 'commandArgs', - 'colorWidgets', - 'relativeWidgets', - ): - setattr(self, '_{}'.format(kwarg), kwargs[kwarg]) + "presetNames", + "commandArgs", + "colorWidgets", + "relativeWidgets", + ): + setattr(self, "_{}".format(kwarg), kwargs[kwarg]) else: - raise ComponentError( - self, 'Nonsensical keywords to trackWidgets.') + raise ComponentError(self, "Nonsensical keywords to trackWidgets.") except ComponentError: continue - if kwarg == 'colorWidgets': + if kwarg == "colorWidgets": + def makeColorFunc(attr): def pickColor_(): self.mergeUndo = False self.pickColor( self._trackedWidgets[attr], - self._colorWidgets[attr] + self._colorWidgets[attr], ) self.mergeUndo = True + return pickColor_ - self._colorFuncs = { - attr: makeColorFunc(attr) for attr in kwargs[kwarg] - } + + self._colorFuncs = {attr: makeColorFunc(attr) for attr in kwargs[kwarg]} for attr, func in self._colorFuncs.items(): self._colorWidgets[attr].clicked.connect(func) self._colorWidgets[attr].setStyleSheet( - "QPushButton {" - "background-color : #FFFFFF; outline: none; }" + "QPushButton {" "background-color : #FFFFFF; outline: none; }" ) - if kwarg == 'relativeWidgets': + if kwarg == "relativeWidgets": # store maximum values of spinBoxes to be scaled appropriately for attr in kwargs[kwarg]: - self._relativeMaximums[attr] = \ - self._trackedWidgets[attr].maximum() + self._relativeMaximums[attr] = self._trackedWidgets[attr].maximum() self.updateRelativeWidgetMaximum(attr) - setattr( - self, attr, getWidgetValue(self._trackedWidgets[attr]) - ) + setattr(self, attr, getWidgetValue(self._trackedWidgets[attr])) self._preUpdate() self._autoUpdate() def pickColor(self, textWidget, button): - '''Use color picker to get color input from the user.''' + """Use color picker to get color input from the user.""" dialog = QtWidgets.QColorDialog() - dialog.setOption(QtWidgets.QColorDialog.ShowAlphaChannel, True) + # TODO alpha channel is not actually shown in most color picker widgets? + dialog.setOption( + QtWidgets.QColorDialog.ColorDialogOption.ShowAlphaChannel, True + ) color = dialog.getColor() if color.isValid(): - RGBstring = '%s,%s,%s' % ( - str(color.red()), str(color.green()), str(color.blue())) - btnStyle = "QPushButton{background-color: %s; outline: none;}" \ - % color.name() + RGBstring = "%s,%s,%s" % ( + str(color.red()), + str(color.green()), + str(color.blue()), + ) + btnStyle = ( + "QPushButton{background-color: %s; outline: none;}" % color.name() + ) textWidget.setText(RGBstring) button.setStyleSheet(btnStyle) @@ -659,25 +689,25 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): self._lockedSize = None def loadUi(self, filename): - '''Load a Qt Designer ui file to use for this component's widget''' + """Load a Qt Designer ui file to use for this component's widget""" return uic.loadUi(os.path.join(self.core.componentsPath, filename)) @property def width(self): if self._lockedSize is None: - return int(self.settings.value('outputWidth')) + return int(self.settings.value("outputWidth")) else: return self._lockedSize[0] @property def height(self): if self._lockedSize is None: - return int(self.settings.value('outputHeight')) + return int(self.settings.value("outputHeight")) else: return self._lockedSize[1] def cancel(self): - '''Stop any lengthy process in response to this variable.''' + """Stop any lengthy process in response to this variable.""" self.canceled = True def reset(self): @@ -688,22 +718,22 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): def relativeWidgetAxis(func): def relativeWidgetAxis(self, attr, *args, **kwargs): hasVerticalWords = ( - lambda attr: - 'height' in attr.lower() or - 'ypos' in attr.lower() or - attr == 'y' + lambda attr: "height" in attr.lower() + or "ypos" in attr.lower() + or attr == "y" ) - if 'axis' not in kwargs: + if "axis" not in kwargs: axis = self.width if hasVerticalWords(attr): axis = self.height - kwargs['axis'] = axis - if 'axis' in kwargs and type(kwargs['axis']) is tuple: - axis = kwargs['axis'][0] + kwargs["axis"] = axis + if "axis" in kwargs and type(kwargs["axis"]) is tuple: + axis = kwargs["axis"][0] if hasVerticalWords(attr): - axis = kwargs['axis'][1] - kwargs['axis'] = axis + axis = kwargs["axis"][1] + kwargs["axis"] = axis return func(self, attr, *args, **kwargs) + return relativeWidgetAxis @relativeWidgetAxis @@ -712,14 +742,20 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): val = self._relativeValues[attr] if val > 50.0: log.warning( - '%s #%s attempted to set %s to dangerously high number %s', - self.__class__.name, self.compPos, attr, val + "%s #%s attempted to set %s to dangerously high number %s", + self.__class__.name, + self.compPos, + attr, + val, ) val = 50.0 - result = math.ceil(kwargs['axis'] * val) + result = math.ceil(kwargs["axis"] * val) log.verbose( - 'Converting %s: f%s to px%s using axis %s', - attr, val, result, kwargs['axis'] + "Converting %s: f%s to px%s using axis %s", + attr, + val, + result, + kwargs["axis"], ) return result @@ -727,65 +763,63 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): def floatValForAttr(self, attr, val=None, **kwargs): if val is None: val = self._trackedWidgets[attr].value() - return val / kwargs['axis'] + return val / kwargs["axis"] def setRelativeWidget(self, attr, floatVal): - '''Set a relative widget using a float''' + """Set a relative widget using a float""" pixelVal = self.pixelValForAttr(attr, floatVal) with blockSignals(self._trackedWidgets[attr]): self._trackedWidgets[attr].setValue(pixelVal) self.update(auto=True) def getOldAttr(self, attr): - ''' - Returns previous state of this attr. Used to determine whether - a relative widget must be updated. Required because undoing/redoing - can make determining the 'previous' value tricky. - ''' + """ + Returns previous state of this attr. Used to determine whether + a relative widget must be updated. Required because undoing/redoing + can make determining the 'previous' value tricky. + """ if self.oldAttrs is not None: return self.oldAttrs[attr] else: try: return getattr(self, attr) except AttributeError: - log.error('Using visible values instead of oldAttrs') + log.error("Using visible values instead of oldAttrs") return self._trackedWidgets[attr].value() def updateRelativeWidget(self, attr): - '''Called by _preUpdate() for each relativeWidget before each update''' + """Called by _preUpdate() for each relativeWidget before each update""" oldUserValue = self.getOldAttr(attr) newUserValue = self._trackedWidgets[attr].value() newRelativeVal = self.floatValForAttr(attr, newUserValue) if attr in self._relativeValues: oldRelativeVal = self._relativeValues[attr] - if oldUserValue == newUserValue \ - and oldRelativeVal != newRelativeVal: + if oldUserValue == newUserValue and oldRelativeVal != newRelativeVal: # Float changed without pixel value changing, which # means the pixel value needs to be updated log.debug( - 'Updating %s #%s\'s relative widget: %s', - self.__class__.name, self.compPos, attr) + "Updating %s #%s's relative widget: %s", + self.__class__.name, + self.compPos, + attr, + ) with blockSignals(self._trackedWidgets[attr]): self.updateRelativeWidgetMaximum(attr) pixelVal = self.pixelValForAttr(attr, oldRelativeVal) self._trackedWidgets[attr].setValue(pixelVal) - if attr not in self._relativeValues \ - or oldUserValue != newUserValue: + if attr not in self._relativeValues or oldUserValue != newUserValue: self._relativeValues[attr] = newRelativeVal def updateRelativeWidgetMaximum(self, attr): - maxRes = int(self.core.resolutions[0].split('x')[0]) - newMaximumValue = self.width * ( - self._relativeMaximums[attr] / - maxRes - ) + maxRes = int(self.core.resolutions[0].split("x")[0]) + newMaximumValue = self.width * (self._relativeMaximums[attr] / maxRes) self._trackedWidgets[attr].setMaximum(int(newMaximumValue)) class ComponentError(RuntimeError): - '''Gives the MainWindow a traceback to display, and cancels the export.''' + """Gives the MainWindow a traceback to display, and cancels the export.""" prevErrors = [] lastTime = time.time() @@ -794,42 +828,46 @@ class ComponentError(RuntimeError): if msg is None and sys.exc_info()[0] is not None: msg = str(sys.exc_info()[1]) else: - msg = 'Unknown error.' - log.error("ComponentError by %s's %s: %s" % ( - caller.name, name, msg)) + msg = "Unknown error." + log.error("ComponentError by %s's %s: %s" % (caller.name, name, msg)) # Don't create multiple windows for quickly repeated messages if len(ComponentError.prevErrors) > 1: ComponentError.prevErrors.pop() ComponentError.prevErrors.insert(0, name) curTime = time.time() - if name in ComponentError.prevErrors[1:] \ - and curTime - ComponentError.lastTime < 1.0: + if ( + name in ComponentError.prevErrors[1:] + and curTime - ComponentError.lastTime < 1.0 + ): return ComponentError.lastTime = time.time() from .toolkit import formatTraceback + if sys.exc_info()[0] is not None: - string = ( - "%s component (#%s): %s encountered %s %s: %s" % ( - caller.__class__.name, - str(caller.compPos), - name, - 'an' if any([ - sys.exc_info()[0].__name__.startswith(vowel) - for vowel in ('A', 'I', 'U', 'O', 'E') - ]) else 'a', - sys.exc_info()[0].__name__, - str(sys.exc_info()[1]) - ) + string = "%s component (#%s): %s encountered %s %s: %s" % ( + caller.__class__.name, + str(caller.compPos), + name, + ( + "an" + if any( + [ + sys.exc_info()[0].__name__.startswith(vowel) + for vowel in ("A", "I", "U", "O", "E") + ] + ) + else "a" + ), + sys.exc_info()[0].__name__, + str(sys.exc_info()[1]), ) detail = formatTraceback(sys.exc_info()[2]) else: string = name detail = "Attributes:\n%s" % ( - "\n".join( - [m for m in dir(caller) if not m.startswith('_')] - ) + "\n".join([m for m in dir(caller) if not m.startswith("_")]) ) super().__init__(string) @@ -837,28 +875,29 @@ class ComponentError(RuntimeError): caller._error.emit(string, detail) -class ComponentUpdate(QtWidgets.QUndoCommand): - '''Command object for making a component action undoable''' +class ComponentUpdate(QUndoCommand): + """Command object for making a component action undoable""" + def __init__(self, parent, oldWidgetVals, modifiedVals): - super().__init__( - 'change %s component #%s' % ( - parent.name, parent.compPos - ) - ) + super().__init__("change %s component #%s" % (parent.name, parent.compPos)) self.undone = False self.res = (int(parent.width), int(parent.height)) self.parent = parent self.oldWidgetVals = { - attr: copy(val) - if attr not in self.parent._relativeWidgets - else self.parent.floatValForAttr(attr, val, axis=self.res) + attr: ( + copy(val) + if attr not in self.parent._relativeWidgets + else self.parent.floatValForAttr(attr, val, axis=self.res) + ) for attr, val in oldWidgetVals.items() if attr in modifiedVals } self.modifiedVals = { - attr: val - if attr not in self.parent._relativeWidgets - else self.parent.floatValForAttr(attr, val, axis=self.res) + attr: ( + val + if attr not in self.parent._relativeWidgets + else self.parent.floatValForAttr(attr, val, axis=self.res) + ) for attr, val in modifiedVals.items() } @@ -877,12 +916,13 @@ class ComponentUpdate(QtWidgets.QUndoCommand): self.modifiedVals[attr] = val else: log.warning( - '%s component settings changed at once. (%s)', - len(self.modifiedVals), repr(self.modifiedVals) + "%s component settings changed at once. (%s)", + len(self.modifiedVals), + repr(self.modifiedVals), ) def id(self): - '''If 2 consecutive updates have same id, Qt will call mergeWith()''' + """If 2 consecutive updates have same id, Qt will call mergeWith()""" return self.id_ def mergeWith(self, other): @@ -890,20 +930,23 @@ class ComponentUpdate(QtWidgets.QUndoCommand): return True def setWidgetValues(self, attrDict): - ''' - Mask the component's usual method to handle our - relative widgets in case the resolution has changed. - ''' + """ + Mask the component's usual method to handle our + relative widgets in case the resolution has changed. + """ newAttrDict = { - attr: val if attr not in self.parent._relativeWidgets - else self.parent.pixelValForAttr(attr, val) + attr: ( + val + if attr not in self.parent._relativeWidgets + else self.parent.pixelValForAttr(attr, val) + ) for attr, val in attrDict.items() } self.parent.setWidgetValues(newAttrDict) def redo(self): if self.undone: - log.info('Redoing component update') + log.info("Redoing component update") self.parent.oldAttrs = self.relativeWidgetValsAfterUndo self.setWidgetValues(self.modifiedVals) self.parent.update(auto=True) @@ -916,7 +959,7 @@ class ComponentUpdate(QtWidgets.QUndoCommand): self.parent._sendUpdateSignal() def undo(self): - log.info('Undoing component update') + log.info("Undoing component update") self.undone = True self.parent.oldAttrs = self.relativeWidgetValsAfterRedo self.setWidgetValues(self.oldWidgetVals) diff --git a/src/components/color.py b/src/components/color.py index 8d0edd2..1f32c23 100644 --- a/src/components/color.py +++ b/src/components/color.py @@ -1,16 +1,16 @@ -from PyQt5 import QtGui +from PyQt6 import QtGui import logging from ..component import Component from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter, PaintColor -log = logging.getLogger('AVP.Components.Color') +log = logging.getLogger("AVP.Components.Color") class Component(Component): - name = 'Color' - version = '1.0.0' + name = "Color" + version = "1.0.0" def widget(self, *args): self.x = 0 @@ -20,48 +20,56 @@ class Component(Component): # disable color #2 until non-default 'fill' option gets changed self.page.lineEdit_color2.setDisabled(True) self.page.pushButton_color2.setDisabled(True) - self.page.spinBox_width.setValue( - int(self.settings.value("outputWidth"))) - self.page.spinBox_height.setValue( - int(self.settings.value("outputHeight"))) + self.page.spinBox_width.setValue(int(self.settings.value("outputWidth"))) + self.page.spinBox_height.setValue(int(self.settings.value("outputHeight"))) self.fillLabels = [ - 'Solid', - 'Linear Gradient', - 'Radial Gradient', + "Solid", + "Linear Gradient", + "Radial Gradient", ] for label in self.fillLabels: 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, - 'color1': self.page.lineEdit_color1, - 'color2': self.page.lineEdit_color2, - }, presetNames={ - 'sizeWidth': 'width', - 'sizeHeight': 'height', - }, colorWidgets={ - 'color1': self.page.pushButton_color1, - 'color2': self.page.pushButton_color2, - }, relativeWidgets=[ - 'x', 'y', - 'sizeWidth', 'sizeHeight', - 'LG_start', 'LG_end', - 'RG_start', 'RG_end', 'RG_centre', - ]) + 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, + "color1": self.page.lineEdit_color1, + "color2": self.page.lineEdit_color2, + }, + presetNames={ + "sizeWidth": "width", + "sizeHeight": "height", + }, + colorWidgets={ + "color1": self.page.pushButton_color1, + "color2": self.page.pushButton_color2, + }, + relativeWidgets=[ + "x", + "y", + "sizeWidth", + "sizeHeight", + "LG_start", + "LG_end", + "RG_start", + "RG_end", + "RG_centre", + ], + ) def update(self): fillType = self.page.comboBox_fill.currentIndex() @@ -86,7 +94,7 @@ class Component(Component): return self.drawFrame(self.width, self.height) def properties(self): - return ['static'] + return ["static"] def frameRender(self, frameNo): log.debug("Color component is drawing frame #%s", frameNo) @@ -96,8 +104,12 @@ class Component(Component): r, g, b = self.color1 shapeSize = (self.sizeWidth, self.sizeHeight) # in default state, skip all this logic and return a plain fill - if self.fillType == 0 and shapeSize == (width, height) \ - and self.x == 0 and self.y == 0: + if ( + self.fillType == 0 + and shapeSize == (width, height) + and self.x == 0 + and self.y == 0 + ): return FloodFrame(width, height, (r, g, b, 255)) # Return a solid image at x, y @@ -120,19 +132,26 @@ class Component(Component): if self.fillType == 1: # Linear Gradient brush = QtGui.QLinearGradient( - self.LG_start, - self.LG_start, - self.LG_end+width/3, - self.LG_end) + float(self.LG_start), + float(self.LG_start), + float(self.LG_end + width / 3), + float(self.LG_end), + ) elif self.fillType == 2: # Radial Gradient brush = QtGui.QRadialGradient( - self.RG_start, - self.RG_end, - w, h, - self.RG_centre) - - brush.setSpread(self.spread) + float(self.RG_start), + float(self.RG_end), + float(w), + float(h), + float(self.RG_centre), + ) + spread = QtGui.QGradient.Spread.PadSpread + if self.spread == 1: + spread = QtGui.QGradient.Spread.ReflectSpread + elif self.spread == 2: + spread = QtGui.QGradient.Spread.RepeatSpread + brush.setSpread(spread) brush.setColorAt(0.0, PaintColor(*self.color1)) if self.trans: brush.setColorAt(1.0, PaintColor(0, 0, 0, 0)) @@ -141,20 +160,17 @@ class Component(Component): else: brush.setColorAt(1.0, PaintColor(*self.color2)) image.setBrush(brush) - image.drawRect( - self.x, self.y, - self.sizeWidth, self.sizeHeight - ) + image.drawRect(self.x, self.y, self.sizeWidth, self.sizeHeight) return image.finalize() def commandHelp(self): - print('Specify a color:\n color=255,255,255') + print("Specify a color:\n color=255,255,255") def command(self, arg): - if '=' in arg: - key, arg = arg.split('=', 1) - if key == 'color': + if "=" in arg: + key, arg = arg.split("=", 1) + if key == "color": self.page.lineEdit_color1.setText(arg) return super().command(arg) diff --git a/src/components/image.py b/src/components/image.py index 42f9564..2393611 100644 --- a/src/components/image.py +++ b/src/components/image.py @@ -1,5 +1,5 @@ from PIL import Image, ImageDraw, ImageEnhance -from PyQt5 import QtGui, QtCore, QtWidgets +from PyQt6 import QtGui, QtCore, QtWidgets import os from ..component import Component @@ -7,37 +7,39 @@ from ..toolkit.frame import BlankFrame class Component(Component): - name = 'Image' - version = '1.0.1' + name = "Image" + version = "1.0.1" 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, - 'stretchScale': self.page.spinBox_scale_stretch, - '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', - }, relativeWidgets=[ - 'xPosition', 'yPosition', 'scale' - ]) + self.trackWidgets( + { + "imagePath": self.page.lineEdit_image, + "scale": self.page.spinBox_scale, + "stretchScale": self.page.spinBox_scale_stretch, + "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", + }, + relativeWidgets=["xPosition", "yPosition", "scale"], + ) def previewRender(self): return self.drawFrame(self.width, self.height) def properties(self): - props = ['static'] + props = ["static"] if not os.path.exists(self.imagePath): - props.append('error') + props.append("error") return props def error(self): @@ -57,17 +59,15 @@ class Component(Component): # Modify image's appearance if self.color != 100: - image = ImageEnhance.Color(image).enhance( - float(self.color / 100) - ) + image = ImageEnhance.Color(image).enhance(float(self.color / 100)) if self.mirror: - image = image.transpose(Image.FLIP_LEFT_RIGHT) + image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) if self.stretched and image.size != (width, height): - image = image.resize((width, height), Image.ANTIALIAS) + image = image.resize((width, height), Image.Resampling.LANCZOS) if scale != 100: newHeight = int((image.height / 100) * scale) newWidth = int((image.width / 100) * scale) - image = image.resize((newWidth, newHeight), Image.ANTIALIAS) + image = image.resize((newWidth, newHeight), Image.Resampling.LANCZOS) # Paste image at correct position frame.paste(image, box=(self.xPosition, self.yPosition)) @@ -79,8 +79,11 @@ class Component(Component): def pickImage(self): imgDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( - self.page, "Choose Image", imgDir, - "Image Files (%s)" % " ".join(self.core.imageFormats)) + self.page, + "Choose Image", + imgDir, + "Image Files (%s)" % " ".join(self.core.imageFormats), + ) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.mergeUndo = False @@ -88,9 +91,9 @@ class Component(Component): self.mergeUndo = True def command(self, arg): - if '=' in arg: - key, arg = arg.split('=', 1) - if key == 'path' and os.path.exists(arg): + if "=" in arg: + key, arg = arg.split("=", 1) + if key == "path" and os.path.exists(arg): try: Image.open(arg) self.page.lineEdit_image.setText(arg) @@ -102,7 +105,7 @@ class Component(Component): super().command(arg) def commandHelp(self): - print('Load an image:\n path=/filepath/to/image.png') + print("Load an image:\n path=/filepath/to/image.png") def savePreset(self): # Maintain the illusion that the scale spinbox is one widget diff --git a/src/components/life.py b/src/components/life.py index e19ed36..b3c2c58 100644 --- a/src/components/life.py +++ b/src/components/life.py @@ -1,16 +1,21 @@ -from PyQt5 import QtGui, QtCore, QtWidgets -from PyQt5.QtWidgets import QUndoCommand +from PyQt6 import QtGui, QtCore, QtWidgets +from PyQt6.QtGui import QUndoCommand from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter import os import math +import logging + from ..component import Component from ..toolkit.frame import BlankFrame, scale +log = logging.getLogger("AVP.Component.Life") + + class Component(Component): - name = 'Conway\'s Game of Life' - version = '1.0.0' + name = "Conway's Game of Life" + version = "1.0.0" def widget(self, *args): super().widget(*args) @@ -18,34 +23,50 @@ class Component(Component): self.updateGridSize() # The initial grid: a "Queen Bee Shuttle" # https://conwaylife.com/wiki/Queen_bee_shuttle - self.startingGrid = set([ - (3, 7), (3, 8), - (4, 7), (4, 8), - (8, 7), - (9, 6), (9, 8), - (10, 5), (10, 9), - (11, 6), (11, 7), (11, 8), - (12, 4), (12, 5), (12, 9), (12, 10), - (23, 6), (23, 7), - (24, 6), (24, 7) - ]) + self.startingGrid = set( + [ + (3, 7), + (3, 8), + (4, 7), + (4, 8), + (8, 7), + (9, 6), + (9, 8), + (10, 5), + (10, 9), + (11, 6), + (11, 7), + (11, 8), + (12, 4), + (12, 5), + (12, 9), + (12, 10), + (23, 6), + (23, 7), + (24, 6), + (24, 7), + ] + ) # Amount of 'bleed' (off-canvas coordinates) on each side of the grid self.bleedSize = 40 self.page.pushButton_pickImage.clicked.connect(self.pickImage) - self.trackWidgets({ - 'tickRate': self.page.spinBox_tickRate, - 'scale': self.page.spinBox_scale, - 'color': self.page.lineEdit_color, - 'shapeType': self.page.comboBox_shapeType, - 'shadow': self.page.checkBox_shadow, - 'customImg': self.page.checkBox_customImg, - 'showGrid': self.page.checkBox_showGrid, - 'image': self.page.lineEdit_image, - }, colorWidgets={ - 'color': self.page.pushButton_color, - }) + self.trackWidgets( + { + "tickRate": self.page.spinBox_tickRate, + "scale": self.page.spinBox_scale, + "color": self.page.lineEdit_color, + "shapeType": self.page.comboBox_shapeType, + "shadow": self.page.checkBox_shadow, + "customImg": self.page.checkBox_customImg, + "showGrid": self.page.checkBox_showGrid, + "image": self.page.lineEdit_image, + }, + colorWidgets={ + "color": self.page.pushButton_color, + }, + ) self.shiftButtons = ( self.page.toolButton_up, self.page.toolButton_down, @@ -56,7 +77,9 @@ class Component(Component): def shiftFunc(i): def shift(): self.shiftGrid(i) + return shift + shiftFuncs = [shiftFunc(i) for i in range(len(self.shiftButtons))] for i, widget in enumerate(self.shiftButtons): widget.clicked.connect(shiftFuncs[i]) @@ -66,8 +89,11 @@ class Component(Component): def pickImage(self): imgDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( - self.page, "Choose Image", imgDir, - "Image Files (%s)" % " ".join(self.core.imageFormats)) + self.page, + "Choose Image", + imgDir, + "Image Files (%s)" % " ".join(self.core.imageFormats), + ) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.mergeUndo = False @@ -98,20 +124,20 @@ class Component(Component): self.page.label_image.setVisible(False) self.page.lineEdit_image.setVisible(False) self.page.pushButton_pickImage.setVisible(False) - enabled = (len(self.startingGrid) > 0) + enabled = len(self.startingGrid) > 0 for widget in self.shiftButtons: widget.setEnabled(enabled) def previewClickEvent(self, pos, size, button): pos = ( math.ceil((pos[0] / size[0]) * self.gridWidth) - 1, - math.ceil((pos[1] / size[1]) * self.gridHeight) - 1 + math.ceil((pos[1] / size[1]) * self.gridHeight) - 1, ) action = ClickGrid(self, pos, button) self.parent.undoStack.push(action) def updateGridSize(self): - w, h = self.core.resolutions[-1].split('x') + w, h = self.core.resolutions[-1].split("x") self.gridWidth = int(int(w) / self.scale) self.gridHeight = int(int(h) / self.scale) self.pxWidth = math.ceil(self.width / self.gridWidth) @@ -125,10 +151,8 @@ class Component(Component): self.tickGrids = {0: self.startingGrid} def properties(self): - if self.customImg and ( - not self.image or not os.path.exists(self.image) - ): - return ['error'] + if self.customImg and (not self.image or not os.path.exists(self.image)): + return ["error"] return [] def error(self): @@ -162,42 +186,47 @@ class Component(Component): drawer = ImageDraw.Draw(frame) rect = ( (drawPtX, drawPtY), - (drawPtX + self.pxWidth, drawPtY + self.pxHeight) + (drawPtX + self.pxWidth, drawPtY + self.pxHeight), ) shape = self.page.comboBox_shapeType.currentText().lower() # Rectangle - if shape == 'rectangle': + if shape == "rectangle": drawer.rectangle(rect, fill=self.color) # Elliptical - elif shape == 'elliptical': + elif shape == "elliptical": drawer.ellipse(rect, fill=self.color) tenthX, tenthY = scale(10, self.pxWidth, self.pxHeight, int) smallerShape = ( - (drawPtX + tenthX + int(tenthX / 4), - drawPtY + tenthY + int(tenthY / 2)), - (drawPtX + self.pxWidth - tenthX - int(tenthX / 4), - drawPtY + self.pxHeight - (tenthY + int(tenthY / 2))) + ( + drawPtX + tenthX + int(tenthX / 4), + drawPtY + tenthY + int(tenthY / 2), + ), + ( + drawPtX + self.pxWidth - tenthX - int(tenthX / 4), + drawPtY + self.pxHeight - (tenthY + int(tenthY / 2)), + ), ) outlineShape = ( - (drawPtX + int(tenthX / 4), - drawPtY + int(tenthY / 2)), - (drawPtX + self.pxWidth - int(tenthX / 4), - drawPtY + self.pxHeight - int(tenthY / 2)) + (drawPtX + int(tenthX / 4), drawPtY + int(tenthY / 2)), + ( + drawPtX + self.pxWidth - int(tenthX / 4), + drawPtY + self.pxHeight - int(tenthY / 2), + ), ) # Circle - if shape == 'circle': + if shape == "circle": drawer.ellipse(outlineShape, fill=self.color) drawer.ellipse(smallerShape, fill=(0, 0, 0, 0)) # Lilypad - elif shape == 'lilypad': + elif shape == "lilypad": drawer.pieslice(smallerShape, 290, 250, fill=self.color) # Pie - elif shape == 'pie': + elif shape == "pie": drawer.pieslice(outlineShape, 35, 320, fill=self.color) hX, hY = scale(50, self.pxWidth, self.pxHeight, int) # halfline @@ -205,12 +234,15 @@ class Component(Component): qX, qY = scale(20, self.pxWidth, self.pxHeight, int) # quarterline # Path - if shape == 'path': + if shape == "path": drawer.ellipse(rect, fill=self.color) rects = { direction: False for direction in ( - 'up', 'down', 'left', 'right', + "up", + "down", + "left", + "right", ) } for cell in self.nearbyCoords(x, y): @@ -218,60 +250,59 @@ class Component(Component): continue if cell[0] == x: if cell[1] < y: - rects['up'] = True + rects["up"] = True if cell[1] > y: - rects['down'] = True + rects["down"] = True if cell[1] == y: if cell[0] < x: - rects['left'] = True + rects["left"] = True if cell[0] > x: - rects['right'] = True + rects["right"] = True for direction, rect in rects.items(): if rect: - if direction == 'up': + if direction == "up": sect = ( (drawPtX, drawPtY), - (drawPtX + self.pxWidth, drawPtY + hY) + (drawPtX + self.pxWidth, drawPtY + hY), ) - elif direction == 'down': + elif direction == "down": sect = ( (drawPtX, drawPtY + hY), - (drawPtX + self.pxWidth, - drawPtY + self.pxHeight) + ( + drawPtX + self.pxWidth, + drawPtY + self.pxHeight, + ), ) - elif direction == 'left': + elif direction == "left": sect = ( (drawPtX, drawPtY), - (drawPtX + hX, - drawPtY + self.pxHeight) + (drawPtX + hX, drawPtY + self.pxHeight), ) - elif direction == 'right': + elif direction == "right": sect = ( (drawPtX + hX, drawPtY), - (drawPtX + self.pxWidth, - drawPtY + self.pxHeight) + ( + drawPtX + self.pxWidth, + drawPtY + self.pxHeight, + ), ) drawer.rectangle(sect, fill=self.color) # Duck - elif shape == 'duck': + elif shape == "duck": duckHead = ( (drawPtX + qX, drawPtY + qY), - (drawPtX + int(qX * 3), drawPtY + int(tY * 2)) + (drawPtX + int(qX * 3), drawPtY + int(tY * 2)), ) duckBeak = ( (drawPtX + hX, drawPtY + qY), - (drawPtX + self.pxWidth + qX, - drawPtY + int(qY * 3)) - ) - duckWing = ( - (drawPtX, drawPtY + hY), - rect[1] + (drawPtX + self.pxWidth + qX, drawPtY + int(qY * 3)), ) + duckWing = ((drawPtX, drawPtY + hY), rect[1]) duckBody = ( (drawPtX + int(qX / 4), drawPtY + int(qY * 3)), - (drawPtX + int(tX * 2), drawPtY + self.pxHeight) + (drawPtX + int(tX * 2), drawPtY + self.pxHeight), ) drawer.ellipse(duckBody, fill=self.color) drawer.ellipse(duckHead, fill=self.color) @@ -279,11 +310,16 @@ class Component(Component): drawer.pieslice(duckBeak, 145, 200, fill=self.color) # Peace - elif shape == 'peace': - line = (( - drawPtX + hX - int(tenthX / 2), drawPtY + int(tenthY / 2)), - (drawPtX + hX + int(tenthX / 2), - drawPtY + self.pxHeight - int(tenthY / 2)) + elif shape == "peace": + line = ( + ( + drawPtX + hX - int(tenthX / 2), + drawPtY + int(tenthY / 2), + ), + ( + drawPtX + hX + int(tenthX / 2), + drawPtY + self.pxHeight - int(tenthY / 2), + ), ) drawer.ellipse(outlineShape, fill=self.color) drawer.ellipse(smallerShape, fill=(0, 0, 0, 0)) @@ -291,21 +327,15 @@ class Component(Component): def slantLine(difference): return ( - (drawPtX + difference), (drawPtY + self.pxHeight - qY) + (drawPtX + difference), + (drawPtY + self.pxHeight - qY), ), ( - (drawPtX + hX), (drawPtY + hY) + (drawPtX + hX), + (drawPtY + hY), ) - drawer.line( - slantLine(qX), - fill=self.color, - width=tenthX - ) - drawer.line( - slantLine(self.pxWidth - qX), - fill=self.color, - width=tenthX - ) + drawer.line(slantLine(qX), fill=self.color, width=tenthX) + drawer.line(slantLine(self.pxWidth - qX), fill=self.color, width=tenthX) for x, y in grid: drawPtX = x * self.pxWidth @@ -331,44 +361,38 @@ class Component(Component): w, h = scale(0.05, self.width, self.height, int) for x in range(self.pxWidth, self.width, self.pxWidth): drawer.rectangle( - ((x, 0), - (x + w, self.height)), + ((x, 0), (x + w, self.height)), fill=self.color, ) for y in range(self.pxHeight, self.height, self.pxHeight): drawer.rectangle( - ((0, y), - (self.width, y + h)), + ((0, y), (self.width, y + h)), fill=self.color, ) return frame def gridForTick(self, tick): - ''' + """ Given a tick number over 0, returns a new grid (a set of tuples). This must compute the previous ticks' grids if not already computed - ''' + """ if tick - 1 not in self.tickGrids: self.tickGrids[tick - 1] = self.gridForTick(tick - 1) - + lastGrid = self.tickGrids[tick - 1] def neighbours(x, y): - return { - cell for cell in self.nearbyCoords(x, y) - if cell in lastGrid - } + return {cell for cell in self.nearbyCoords(x, y) if cell in lastGrid} newGrid = set() # Copy cells from the previous grid if they have 2 or 3 neighbouring cells # and if they are within the grid or its bleed area (off-canvas area) for x, y in lastGrid: if ( - -self.bleedSize > x > self.gridWidth + self.bleedSize - or - -self.bleedSize > y > self.gridHeight + self.bleedSize - ): + -self.bleedSize > x > self.gridWidth + self.bleedSize + or -self.bleedSize > y > self.gridHeight + self.bleedSize + ): continue surrounding = len(neighbours(x, y)) if surrounding == 2 or surrounding == 3: @@ -376,7 +400,8 @@ class Component(Component): # Find positions around living cells which must be checked for reproduction potentialNewCells = { - coordTup for origin in lastGrid + coordTup + for origin in lastGrid for coordTup in list(self.nearbyCoords(*origin)) } # Check for reproduction @@ -392,11 +417,11 @@ class Component(Component): def savePreset(self): pr = super().savePreset() - pr['GRID'] = sorted(self.startingGrid) + pr["GRID"] = sorted(self.startingGrid) return pr def loadPreset(self, pr, *args): - self.startingGrid = set(pr['GRID']) + self.startingGrid = set(pr["GRID"]) if self.startingGrid: for widget in self.shiftButtons: widget.setEnabled(True) @@ -414,15 +439,17 @@ class Component(Component): class ClickGrid(QUndoCommand): - def __init__(self, comp, pos, id_): - super().__init__( - "click %s component #%s" % (comp.name, comp.compPos)) + def __init__(self, comp, pos, button): + super().__init__("click %s component #%s" % (comp.name, comp.compPos)) self.comp = comp self.pos = [pos] - self.id_ = id_ + if button == QtCore.Qt.MouseButton.RightButton: + self.button = 2 + else: + self.button = 1 def id(self): - return self.id_ + return self.button def mergeWith(self, other): self.pos.extend(other.pos) @@ -439,21 +466,21 @@ class ClickGrid(QUndoCommand): self.comp.update(auto=True) def redo(self): - if self.id_ == 1: # Left-click + if self.button == 1: # Left-click self.add() - elif self.id_ == 2: # Right-click + elif self.button == 2: # Right-click self.remove() def undo(self): - if self.id_ == 1: # Left-click + if self.button == 1: # Left-click self.remove() - elif self.id_ == 2: # Right-click + elif self.button == 2: # Right-click self.add() + class ShiftGrid(QUndoCommand): def __init__(self, comp, direction): - super().__init__( - "change %s component #%s" % (comp.name, comp.compPos)) + super().__init__("change %s component #%s" % (comp.name, comp.compPos)) self.comp = comp self.direction = direction self.distance = 1 @@ -466,10 +493,7 @@ class ShiftGrid(QUndoCommand): return True def newGrid(self, Xchange, Ychange): - return { - (x + Xchange, y + Ychange) - for x, y in self.comp.startingGrid - } + return {(x + Xchange, y + Ychange) for x, y in self.comp.startingGrid} def redo(self): if self.direction == 0: diff --git a/src/components/life.ui b/src/components/life.ui index 3d6ad5a..30cf9d0 100644 --- a/src/components/life.ui +++ b/src/components/life.ui @@ -372,7 +372,7 @@ p, li { white-space: pre-wrap; } <p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- A cell with more than 3 neighbours will die from overpopulation.</p> <p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- An empty space surrounded by 3 live cells will cause reproduction.</p></body></html></string> </property> - <property name="tabStopWidth"> + <property name="tabStopDistance"> <number>80</number> </property> <property name="textInteractionFlags"> diff --git a/src/components/original.py b/src/components/original.py index 289e982..fad797b 100644 --- a/src/components/original.py +++ b/src/components/original.py @@ -1,9 +1,5 @@ import numpy from PIL import Image, ImageDraw -from PyQt5 import QtGui, QtCore, QtWidgets -from PyQt5.QtGui import QColor -import os -import time from copy import copy from ..component import Component @@ -11,14 +7,14 @@ from ..toolkit.frame import BlankFrame class Component(Component): - name = 'Classic Visualizer' - version = '1.0.0' + name = "Classic Visualizer" + version = "1.0.0" def names(*args): - return ['Original Audio Visualization'] + return ["Original Audio Visualization"] def properties(self): - return ['pcm'] + return ["pcm"] def widget(self, *args): self.scale = 20 @@ -31,23 +27,30 @@ class Component(Component): self.page.comboBox_visLayout.addItem("Top") self.page.comboBox_visLayout.setCurrentIndex(0) - self.page.lineEdit_visColor.setText('255,255,255') - - self.trackWidgets({ - 'visColor': self.page.lineEdit_visColor, - 'layout': self.page.comboBox_visLayout, - 'scale': self.page.spinBox_scale, - 'y': self.page.spinBox_y, - 'smooth': self.page.spinBox_smooth, - }, colorWidgets={ - 'visColor': self.page.pushButton_visColor, - }, relativeWidgets=[ - 'y', - ]) + self.page.lineEdit_visColor.setText("255,255,255") + + self.trackWidgets( + { + "visColor": self.page.lineEdit_visColor, + "layout": self.page.comboBox_visLayout, + "scale": self.page.spinBox_scale, + "y": self.page.spinBox_y, + "smooth": self.page.spinBox_smooth, + }, + colorWidgets={ + "visColor": self.page.pushButton_visColor, + }, + relativeWidgets=[ + "y", + ], + ) def previewRender(self): spectrum = numpy.fromfunction( - lambda x: float(self.scale)/2500*(x-128)**2, (255,), dtype="int16") + lambda x: float(self.scale) / 2500 * (x - 128) ** 2, + (255,), + dtype="int16", + ) return self.drawBars( self.width, self.height, spectrum, self.visColor, self.layout ) @@ -63,41 +66,53 @@ class Component(Component): if self.canceled: break self.lastSpectrum = self.transformData( - i, self.completeAudioArray, self.sampleSize, - self.smoothConstantDown, self.smoothConstantUp, - self.lastSpectrum) + i, + self.completeAudioArray, + self.sampleSize, + self.smoothConstantDown, + self.smoothConstantUp, + self.lastSpectrum, + ) self.spectrumArray[i] = copy(self.lastSpectrum) - progress = int(100*(i/len(self.completeAudioArray))) + progress = int(100 * (i / len(self.completeAudioArray))) if progress >= 100: progress = 100 - pStr = "Analyzing audio: "+str(progress)+'%' + pStr = "Analyzing audio: " + str(progress) + "%" self.progressBarSetText.emit(pStr) self.progressBarUpdate.emit(int(progress)) def frameRender(self, frameNo): arrayNo = frameNo * self.sampleSize return self.drawBars( - self.width, self.height, + self.width, + self.height, self.spectrumArray[arrayNo], - self.visColor, self.layout) + self.visColor, + self.layout, + ) def transformData( - self, i, completeAudioArray, sampleSize, - smoothConstantDown, smoothConstantUp, lastSpectrum): + self, + i, + completeAudioArray, + sampleSize, + smoothConstantDown, + smoothConstantUp, + lastSpectrum, + ): if len(completeAudioArray) < (i + sampleSize): sampleSize = len(completeAudioArray) - i window = numpy.hanning(sampleSize) - data = completeAudioArray[i:i+sampleSize][::1] * window + data = completeAudioArray[i : i + sampleSize][::1] * window paddedSampleSize = 2048 - paddedData = numpy.pad( - data, (0, paddedSampleSize - sampleSize), 'constant') + paddedData = numpy.pad(data, (0, paddedSampleSize - sampleSize), "constant") spectrum = numpy.fft.fft(paddedData) sample_rate = 44100 - frequencies = numpy.fft.fftfreq(len(spectrum), 1./sample_rate) + frequencies = numpy.fft.fftfreq(len(spectrum), 1.0 / sample_rate) - y = abs(spectrum[0:int(paddedSampleSize/2) - 1]) + y = abs(spectrum[0 : int(paddedSampleSize / 2) - 1]) # filter the noise away # y[y<80] = 0 @@ -106,22 +121,26 @@ class Component(Component): y[numpy.isinf(y)] = 0 if lastSpectrum is not None: - lastSpectrum[y < lastSpectrum] = \ - y[y < lastSpectrum] * smoothConstantDown + \ - lastSpectrum[y < lastSpectrum] * (1 - smoothConstantDown) - - lastSpectrum[y >= lastSpectrum] = \ - y[y >= lastSpectrum] * smoothConstantUp + \ - lastSpectrum[y >= lastSpectrum] * (1 - smoothConstantUp) + lastSpectrum[y < lastSpectrum] = y[ + y < lastSpectrum + ] * smoothConstantDown + lastSpectrum[y < lastSpectrum] * ( + 1 - smoothConstantDown + ) + + lastSpectrum[y >= lastSpectrum] = y[ + y >= lastSpectrum + ] * smoothConstantUp + lastSpectrum[y >= lastSpectrum] * ( + 1 - smoothConstantUp + ) else: lastSpectrum = y - x = frequencies[0:int(paddedSampleSize/2) - 1] + x = frequencies[0 : int(paddedSampleSize / 2) - 1] return lastSpectrum def drawBars(self, width, height, spectrum, color, layout): - vH = height-height/8 + vH = height - height / 8 bF = width / 64 bH = bF / 2 bQ = bF / 4 @@ -133,72 +152,92 @@ class Component(Component): bP = height / 1200 for j in range(0, 63): - draw.rectangle(( - bH + j * bF, vH+bQ, bH + j * bF + bF, vH + bQ - - spectrum[j * 4] * bP - bH), fill=color2) - - draw.rectangle(( - bH + bQ + j * bF, vH, bH + bQ + j * bF + bH, vH - - spectrum[j * 4] * bP), fill=color) - - imBottom = imTop.transpose(Image.FLIP_TOP_BOTTOM) + x0 = bH + j * bF + y0 = vH + bQ + y1 = vH + bQ - spectrum[j * 4] * bP - bH + x1 = bH + j * bF + bF + draw.rectangle( + ( + x0, + y0 if y0 < y1 else y1, + x1 if x1 > x0 else x0, + y1 if y0 < y1 else y0, + ), + fill=color2, + ) + + x0 = bH + bQ + j * bF + y0 = vH + x1 = bH + bQ + j * bF + bH + y1 = vH - spectrum[j * 4] * bP + draw.rectangle( + ( + x0, + y0 if y0 < y1 else y1, + x1 if x1 > x0 else x0, + y1 if y0 < y1 else y0, + ), + fill=color, + ) + + imBottom = imTop.transpose(Image.Transpose.FLIP_TOP_BOTTOM) im = BlankFrame(width, height) if layout == 0: # Classic - y = self.y - int(height/100*43) + y = self.y - int(height / 100 * 43) im.paste(imTop, (0, y), mask=imTop) - y = self.y + int(height/100*43) + y = self.y + int(height / 100 * 43) im.paste(imBottom, (0, y), mask=imBottom) if layout == 1: # Split - y = self.y + int(height/100*10) + y = self.y + int(height / 100 * 10) im.paste(imTop, (0, y), mask=imTop) - y = self.y - int(height/100*10) + y = self.y - int(height / 100 * 10) im.paste(imBottom, (0, y), mask=imBottom) if layout == 2: # Bottom - y = self.y + int(height/100*10) + y = self.y + int(height / 100 * 10) im.paste(imTop, (0, y), mask=imTop) if layout == 3: # Top - y = self.y - int(height/100*10) + y = self.y - int(height / 100 * 10) im.paste(imBottom, (0, y), mask=imBottom) return im def command(self, arg): - if '=' in arg: - key, arg = arg.split('=', 1) + if "=" in arg: + key, arg = arg.split("=", 1) try: - if key == 'color': + if key == "color": self.page.lineEdit_visColor.setText(arg) return - elif key == 'layout': - if arg == 'classic': + elif key == "layout": + if arg == "classic": self.page.comboBox_visLayout.setCurrentIndex(0) - elif arg == 'split': + elif arg == "split": self.page.comboBox_visLayout.setCurrentIndex(1) - elif arg == 'bottom': + elif arg == "bottom": self.page.comboBox_visLayout.setCurrentIndex(2) - elif arg == 'top': + elif arg == "top": self.page.comboBox_visLayout.setCurrentIndex(3) return - elif key == 'scale': + elif key == "scale": arg = int(arg) self.page.spinBox_scale.setValue(arg) return - elif key == 'y': + elif key == "y": arg = int(arg) self.page.spinBox_y.setValue(arg) return except ValueError: - print('You must enter a number.') + print("You must enter a number.") quit(1) super().command(arg) def commandHelp(self): - print('Give a layout name:\n layout=[classic/split/bottom/top]') - print('Specify a color:\n color=255,255,255') - print('Visualizer scale (20 is default):\n scale=number') - print('Y position:\n y=number') + print("Give a layout name:\n layout=[classic/split/bottom/top]") + print("Specify a color:\n color=255,255,255") + print("Visualizer scale (20 is default):\n scale=number") + print("Y position:\n y=number") diff --git a/src/components/sound.py b/src/components/sound.py index 118ea23..2df8e38 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -1,4 +1,4 @@ -from PyQt5 import QtGui, QtCore, QtWidgets +from PyQt6 import QtGui, QtCore, QtWidgets import os from ..component import Component @@ -6,25 +6,28 @@ from ..toolkit.frame import BlankFrame class Component(Component): - name = 'Sound' - version = '1.0.0' + name = "Sound" + version = "1.0.0" 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, - }, commandArgs={ - 'sound': None, - }) + self.trackWidgets( + { + "sound": self.page.lineEdit_sound, + "chorus": self.page.checkBox_chorus, + "delay": self.page.spinBox_delay, + "volume": self.page.spinBox_volume, + }, + commandArgs={ + "sound": None, + }, + ) def properties(self): - props = ['static', 'audio'] + props = ["static", "audio"] if not os.path.exists(self.sound): - props.append('error') + props.append("error") return props def error(self): @@ -36,20 +39,22 @@ class Component(Component): def audio(self): params = {} if self.delay != 0.0: - params['adelay'] = '=%s' % str(int(self.delay * 1000.00)) + params["adelay"] = "=%s" % str(int(self.delay * 1000.00)) if self.chorus: - params['chorus'] = \ - '=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3' + params["chorus"] = "=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3" if self.volume != 1.0: - params['volume'] = '=%s:replaygain_noclip=0' % str(self.volume) + params["volume"] = "=%s:replaygain_noclip=0" % str(self.volume) return (self.sound, params) def pickSound(self): sndDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( - self.page, "Choose Sound", sndDir, - "Audio Files (%s)" % " ".join(self.core.audioFormats)) + self.page, + "Choose Sound", + sndDir, + "Audio Files (%s)" % " ".join(self.core.audioFormats), + ) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.mergeUndo = False @@ -57,14 +62,13 @@ class Component(Component): self.mergeUndo = True def commandHelp(self): - print('Path to audio file:\n path=/filepath/to/sound.ogg') + print("Path to audio file:\n path=/filepath/to/sound.ogg") def command(self, arg): - if '=' in arg: - key, arg = arg.split('=', 1) - if key == 'path': - if '*%s' % os.path.splitext(arg)[1] \ - not in self.core.audioFormats: + if "=" in arg: + key, arg = arg.split("=", 1) + if key == "path": + if "*%s" % os.path.splitext(arg)[1] not in self.core.audioFormats: print("Not a supported audio format") quit(1) self.page.lineEdit_sound.setText(arg) diff --git a/src/components/spectrum.py b/src/components/spectrum.py index 30d5426..062ebc7 100644 --- a/src/components/spectrum.py +++ b/src/components/spectrum.py @@ -1,5 +1,5 @@ from PIL import Image -from PyQt5 import QtGui, QtCore, QtWidgets +from PyQt6 import QtGui, QtCore, QtWidgets import os import math import subprocess @@ -10,16 +10,20 @@ from ..component import Component from ..toolkit.frame import BlankFrame, scale from ..toolkit import checkOutput, connectWidget from ..toolkit.ffmpeg import ( - openPipe, closePipe, getAudioDuration, FfmpegVideo, exampleSound + openPipe, + closePipe, + getAudioDuration, + FfmpegVideo, + exampleSound, ) -log = logging.getLogger('AVP.Components.Spectrum') +log = logging.getLogger("AVP.Components.Spectrum") class Component(Component): - name = 'Spectrum' - version = '1.0.1' + name = "Spectrum" + version = "1.0.1" def widget(self, *args): self.previewFrame = None @@ -30,34 +34,36 @@ class Component(Component): self.previewSize = (214, 120) self.previewPipe = None - if hasattr(self.parent, 'lineEdit_audioFile'): + if hasattr(self.parent, "lineEdit_audioFile"): # update preview when audio file changes (if genericPreview is off) - self.parent.lineEdit_audioFile.textChanged.connect( - self.update - ) + self.parent.lineEdit_audioFile.textChanged.connect(self.update) - self.trackWidgets({ - 'filterType': self.page.comboBox_filterType, - 'window': self.page.comboBox_window, - 'mode': self.page.comboBox_mode, - 'amplitude': self.page.comboBox_amplitude0, - 'amplitude1': self.page.comboBox_amplitude1, - 'amplitude2': self.page.comboBox_amplitude2, - 'display': self.page.comboBox_display, - 'zoom': self.page.spinBox_zoom, - 'tc': self.page.spinBox_tc, - 'x': self.page.spinBox_x, - 'y': self.page.spinBox_y, - 'mirror': self.page.checkBox_mirror, - 'draw': self.page.checkBox_draw, - 'scale': self.page.spinBox_scale, - 'color': self.page.comboBox_color, - 'compress': self.page.checkBox_compress, - 'mono': self.page.checkBox_mono, - 'hue': self.page.spinBox_hue, - }, relativeWidgets=[ - 'x', 'y', - ]) + self.trackWidgets( + { + "filterType": self.page.comboBox_filterType, + "window": self.page.comboBox_window, + "mode": self.page.comboBox_mode, + "amplitude": self.page.comboBox_amplitude0, + "amplitude1": self.page.comboBox_amplitude1, + "amplitude2": self.page.comboBox_amplitude2, + "display": self.page.comboBox_display, + "zoom": self.page.spinBox_zoom, + "tc": self.page.spinBox_tc, + "x": self.page.spinBox_x, + "y": self.page.spinBox_y, + "mirror": self.page.checkBox_mirror, + "draw": self.page.checkBox_draw, + "scale": self.page.spinBox_scale, + "color": self.page.comboBox_color, + "compress": self.page.checkBox_compress, + "mono": self.page.checkBox_mono, + "hue": self.page.spinBox_hue, + }, + relativeWidgets=[ + "x", + "y", + ], + ) for widget in self._trackedWidgets.values(): connectWidget(widget, lambda: self.changed()) @@ -78,18 +84,18 @@ class Component(Component): def previewRender(self): changedSize = self.updateChunksize() - if not changedSize \ - and not self.changedOptions \ - and self.previewFrame is not None: - log.debug( - 'Spectrum #%s is reusing old preview frame' % self.compPos) + if ( + not changedSize + and not self.changedOptions + and self.previewFrame is not None + ): + log.debug("Spectrum #%s is reusing old preview frame" % self.compPos) return self.previewFrame frame = self.getPreviewFrame() self.changedOptions = False if not frame: - log.warning( - 'Spectrum #%s failed to create a preview frame' % self.compPos) + log.warning("Spectrum #%s failed to create a preview frame" % self.compPos) self.previewFrame = None return BlankFrame(self.width, self.height) else: @@ -105,10 +111,12 @@ class Component(Component): self.video = FfmpegVideo( inputPath=self.audioFile, filter_=self.makeFfmpegFilter(), - width=w, height=h, + width=w, + height=h, chunkSize=self.chunkSize, frameRate=int(self.settings.value("outputFrameRate")), - parent=self.parent, component=self, + parent=self.parent, + component=self, ) def frameRender(self, frameNo): @@ -133,38 +141,55 @@ class Component(Component): command = [ self.core.FFMPEG_BIN, - '-thread_queue_size', '512', - '-r', str(self.settings.value("outputFrameRate")), - '-ss', "{0:.3f}".format(startPt), - '-i', - self.core.junkStream - if genericPreview else inputFile, - '-f', 'image2pipe', - '-pix_fmt', 'rgba', + "-thread_queue_size", + "512", + "-r", + str(self.settings.value("outputFrameRate")), + "-ss", + "{0:.3f}".format(startPt), + "-i", + self.core.junkStream if genericPreview else inputFile, + "-f", + "image2pipe", + "-pix_fmt", + "rgba", ] command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt)) - command.extend([ - '-an', - '-s:v', '%sx%s' % scale(self.scale, self.width, self.height, str), - '-codec:v', 'rawvideo', '-', - '-frames:v', '1', - ]) + command.extend( + [ + "-an", + "-s:v", + "%sx%s" % scale(self.scale, self.width, self.height, str), + "-codec:v", + "rawvideo", + "-", + "-frames:v", + "1", + ] + ) if self.core.logEnabled: logFilename = os.path.join( - self.core.logDir, 'preview_%s.log' % str(self.compPos)) - log.debug('Creating FFmpeg process (log at %s)' % logFilename) - with open(logFilename, 'w') as logf: - logf.write(" ".join(command) + '\n\n') - with open(logFilename, 'a') as logf: + self.core.logDir, "preview_%s.log" % str(self.compPos) + ) + log.debug("Creating FFmpeg process (log at %s)" % logFilename) + with open(logFilename, "w") as logf: + logf.write(" ".join(command) + "\n\n") + with open(logFilename, "a") as logf: self.previewPipe = openPipe( - command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, - stderr=logf, bufsize=10**8 + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=logf, + bufsize=10**8, ) else: self.previewPipe = openPipe( - command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, bufsize=10**8 + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=10**8, ) byteFrame = self.previewPipe.stdout.read(self.chunkSize) closePipe(self.previewPipe) @@ -173,132 +198,151 @@ class Component(Component): return frame def makeFfmpegFilter(self, preview=False, startPt=0): - '''Makes final FFmpeg filter command''' + """Makes final FFmpeg filter command""" def getFilterComplexCommand(): - '''Inner function that creates the final, complex part of the filter command''' + """Inner function that creates the final, complex part of the filter command""" nonlocal self genericPreview = self.settings.value("pref_genericPreview") def getFilterComplexCommandForType(): - '''Determine portion of filter command that changes depending on selected type''' + """Determine portion of filter command that changes depending on selected type""" nonlocal self if preview: w, h = self.previewSize else: w, h = (self.width, self.height) color = self.page.comboBox_color.currentText().lower() - + if self.filterType == 0: # Spectrum if self.amplitude == 0: - amplitude = 'sqrt' + amplitude = "sqrt" elif self.amplitude == 1: - amplitude = 'cbrt' + amplitude = "cbrt" elif self.amplitude == 2: - amplitude = '4thrt' + amplitude = "4thrt" elif self.amplitude == 3: - amplitude = '5thrt' + amplitude = "5thrt" elif self.amplitude == 4: - amplitude = 'lin' + amplitude = "lin" elif self.amplitude == 5: - amplitude = 'log' + amplitude = "log" filter_ = ( - f'showspectrum=s={w}x{h}:' - 'slide=scroll:' - f'win_func={self.page.comboBox_window.currentText()}:' - f'color={color}:' - f'scale={amplitude},' - 'colorkey=color=black:' - 'similarity=0.1:blend=0.5' + f"showspectrum=s={w}x{h}:" + "slide=scroll:" + f"win_func={self.page.comboBox_window.currentText()}:" + f"color={color}:" + f"scale={amplitude}," + "colorkey=color=black:" + "similarity=0.1:blend=0.5" ) elif self.filterType == 1: # Histogram if self.amplitude1 == 0: - amplitude = 'log' + amplitude = "log" elif self.amplitude1 == 1: - amplitude = 'lin' + amplitude = "lin" if self.display == 0: - display = 'log' + display = "log" elif self.display == 1: - display = 'sqrt' + display = "sqrt" elif self.display == 2: - display = 'cbrt' + display = "cbrt" elif self.display == 3: - display = 'lin' + display = "lin" elif self.display == 4: - display = 'rlog' + display = "rlog" filter_ = ( f'ahistogram=r={str(self.settings.value("outputFrameRate"))}:' - f's={w}x{h}:' - 'dmode=separate:' - f'ascale={amplitude}:' - f'scale={display}' + f"s={w}x{h}:" + "dmode=separate:" + f"ascale={amplitude}:" + f"scale={display}" ) elif self.filterType == 2: # Vector Scope if self.amplitude2 == 0: - amplitude = 'log' + amplitude = "log" elif self.amplitude2 == 1: - amplitude = 'sqrt' + amplitude = "sqrt" elif self.amplitude2 == 2: - amplitude = 'cbrt' + amplitude = "cbrt" elif self.amplitude2 == 3: - amplitude = 'lin' + amplitude = "lin" m = self.page.comboBox_mode.currentText() filter_ = ( - f'avectorscope=s={w}x{h}:' + f"avectorscope=s={w}x{h}:" f'draw={"line" if self.draw else "dot"}:' - f'm={m}:' - f'scale={amplitude}:' - f'zoom={str(self.zoom)}' + f"m={m}:" + f"scale={amplitude}:" + f"zoom={str(self.zoom)}" ) elif self.filterType == 3: # Musical Scale filter_ = ( f'showcqt=r={str(self.settings.value("outputFrameRate"))}:' - f's={w}x{h}:' - 'count=30:' - 'text=0:' - f'tc={str(self.tc)},' - 'colorkey=color=black:' - 'similarity=0.1:blend=0.5' + f"s={w}x{h}:" + "count=30:" + "text=0:" + f"tc={str(self.tc)}," + "colorkey=color=black:" + "similarity=0.1:blend=0.5" ) elif self.filterType == 4: # Phase filter_ = ( f'aphasemeter=r={str(self.settings.value("outputFrameRate"))}:' - f's={w}x{h}:' - 'video=1 [atrash][vtmp1]; ' - '[atrash] anullsink; ' - '[vtmp1] colorkey=color=black:' - 'similarity=0.1:blend=0.5, ' - 'crop=in_w/8:in_h:(in_w/8)*7:0 ' + f"s={w}x{h}:" + "video=1 [atrash][vtmp1]; " + "[atrash] anullsink; " + "[vtmp1] colorkey=color=black:" + "similarity=0.1:blend=0.5, " + "crop=in_w/8:in_h:(in_w/8)*7:0 " ) return filter_ - if self.filterType < 2: - exampleSnd = exampleSound('freq') + exampleSnd = exampleSound("freq") elif self.filterType == 2 or self.filterType == 4: - exampleSnd = exampleSound('stereo') + exampleSnd = exampleSound("stereo") elif self.filterType == 3: - exampleSnd = exampleSound('white') - compression = 'compand=gain=4,' if self.compress else '' - aformat = 'aformat=channel_layouts=mono,' if self.mono and self.filterType not in (2, 4) else '' + exampleSnd = exampleSound("white") + compression = "compand=gain=4," if self.compress else "" + aformat = ( + "aformat=channel_layouts=mono," + if self.mono and self.filterType not in (2, 4) + else "" + ) filter_ = getFilterComplexCommandForType() - hflip = 'hflip, ' if self.mirror else '' - trim = 'trim=start=%s:end=%s, ' % ("{0:.3f}".format(startPt + 12), "{0:.3f}".format(startPt + 12.5)) if preview else '' - scale_ = 'scale=%sx%s' % scale(self.scale, self.width, self.height, str) - hue = ', hue=h=%s:s=10' % str(self.hue) if self.hue > 0 and self.filterType != 3 else '' - convolution = ', convolution=-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2' if self.filterType == 3 else '' - + hflip = "hflip, " if self.mirror else "" + trim = ( + "trim=start=%s:end=%s, " + % ( + "{0:.3f}".format(startPt + 12), + "{0:.3f}".format(startPt + 12.5), + ) + if preview + else "" + ) + scale_ = "scale=%sx%s" % scale(self.scale, self.width, self.height, str) + hue = ( + ", hue=h=%s:s=10" % str(self.hue) + if self.hue > 0 and self.filterType != 3 + else "" + ) + convolution = ( + ", convolution=-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2" + if self.filterType == 3 + else "" + ) + return ( f"{exampleSnd if preview and genericPreview else '[0:a] '}" f"{compression}{aformat}{filter_} [v1]; " f"[v1] {hflip}{trim}{scale_}{hue}{convolution} [v]" ) - return [ - '-filter_complex', + "-filter_complex", getFilterComplexCommand(), - '-map', '[v]', + "-map", + "[v]", ] def updateChunksize(self): @@ -311,9 +355,9 @@ class Component(Component): def finalizeFrame(self, imageData): try: image = Image.frombytes( - 'RGBA', + "RGBA", scale(self.scale, self.width, self.height, int), - imageData + imageData, ) self._image = image except ValueError: diff --git a/src/components/text.py b/src/components/text.py index 3238d2a..40c981a 100644 --- a/src/components/text.py +++ b/src/components/text.py @@ -1,22 +1,22 @@ from PIL import ImageEnhance, ImageFilter, ImageChops -from PyQt5.QtGui import QColor, QFont -from PyQt5 import QtGui, QtCore, QtWidgets +from PyQt6.QtGui import QColor, QFont +from PyQt6 import QtGui, QtCore, QtWidgets import os import logging from ..component import Component from ..toolkit.frame import FramePainter, PaintColor -log = logging.getLogger('AVP.Components.Text') +log = logging.getLogger("AVP.Components.Text") class Component(Component): - name = 'Title Text' - version = '1.0.1' + name = "Title Text" + version = "1.0.1" def widget(self, *args): super().widget(*args) - self.title = 'Text' + self.title = "Text" self.alignment = 1 self.titleFont = QFont() self.fontSize = self.height / 13.5 @@ -29,33 +29,44 @@ class Component(Component): self.page.lineEdit_title.setText(self.title) self.page.pushButton_center.clicked.connect(self.centerXY) - self.page.fontComboBox_titleFont.currentFontChanged.connect(self._sendUpdateSignal) + self.page.fontComboBox_titleFont.currentFontChanged.connect( + self._sendUpdateSignal + ) # The QFontComboBox must be connected directly to the Qt Signal # which triggers the preview to update. # This unfortunately makes changing the font into a non-undoable action. # Must be something broken in the conversion to a ComponentAction - self.trackWidgets({ - 'textColor': self.page.lineEdit_textColor, - '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, - 'fontStyle': self.page.comboBox_fontStyle, - 'stroke': self.page.spinBox_stroke, - 'strokeColor': self.page.lineEdit_strokeColor, - 'shadow': self.page.checkBox_shadow, - 'shadX': self.page.spinBox_shadX, - 'shadY': self.page.spinBox_shadY, - 'shadBlur': self.page.spinBox_shadBlur, - }, colorWidgets={ - 'textColor': self.page.pushButton_textColor, - 'strokeColor': self.page.pushButton_strokeColor, - }, relativeWidgets=[ - 'xPosition', 'yPosition', 'fontSize', - 'stroke', 'shadX', 'shadY', 'shadBlur' - ]) + self.trackWidgets( + { + "textColor": self.page.lineEdit_textColor, + "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, + "fontStyle": self.page.comboBox_fontStyle, + "stroke": self.page.spinBox_stroke, + "strokeColor": self.page.lineEdit_strokeColor, + "shadow": self.page.checkBox_shadow, + "shadX": self.page.spinBox_shadX, + "shadY": self.page.spinBox_shadY, + "shadBlur": self.page.spinBox_shadBlur, + }, + colorWidgets={ + "textColor": self.page.pushButton_textColor, + "strokeColor": self.page.pushButton_strokeColor, + }, + relativeWidgets=[ + "xPosition", + "yPosition", + "fontSize", + "stroke", + "shadX", + "shadY", + "shadBlur", + ], + ) self.centerXY() def update(self): @@ -74,20 +85,23 @@ class Component(Component): self.page.spinBox_shadBlur.setHidden(True) def centerXY(self): - self.setRelativeWidget('xPosition', 0.5) - self.setRelativeWidget('yPosition', 0.521) + self.setRelativeWidget("xPosition", 0.5) + self.setRelativeWidget("yPosition", 0.521) def getXY(self): - '''Returns true x, y after considering alignment settings''' + """Returns true x, y after considering alignment settings""" fm = QtGui.QFontMetrics(self.titleFont) - x = self.pixelValForAttr('xPosition') + text_width = fm.boundingRect(self.title).width() + x = self.pixelValForAttr("xPosition") - if self.alignment == 1: # Middle - offset = int(fm.width(self.title)/2) - x -= offset - if self.alignment == 2: # Right - offset = fm.width(self.title) - x -= offset + if self.alignment == 1: # Middle + offset = int(text_width / 2) + elif self.alignment == 2: # Right + offset = text_width + else: + raise ValueError(f"Alignment value {self.alignment} unknown") + + x -= offset return x, self.yPosition @@ -95,21 +109,21 @@ class Component(Component): super().loadPreset(pr, *args) font = QFont() - font.fromString(pr['titleFont']) + font.fromString(pr["titleFont"]) self.page.fontComboBox_titleFont.setCurrentFont(font) def savePreset(self): saveValueStore = super().savePreset() - saveValueStore['titleFont'] = self.titleFont.toString() + saveValueStore["titleFont"] = self.titleFont.toString() return saveValueStore def previewRender(self): return self.addText(self.width, self.height) def properties(self): - props = ['static'] + props = ["static"] if not self.title: - props.append('error') + props.append("error") return props def error(self): @@ -121,26 +135,26 @@ class Component(Component): def addText(self, width, height): font = self.titleFont font.setPixelSize(self.fontSize) - font.setStyle(QFont.StyleNormal) - font.setWeight(QFont.Normal) - font.setCapitalization(QFont.MixedCase) + font.setStyle(QFont.Style.StyleNormal) + font.setWeight(QFont.Weight.Normal) + font.setCapitalization(QFont.Capitalization.MixedCase) if self.fontStyle == 1: - font.setWeight(QFont.DemiBold) + font.setWeight(QFont.Weight.DemiBold) if self.fontStyle == 2: - font.setWeight(QFont.Bold) + font.setWeight(QFont.Weight.Bold) elif self.fontStyle == 3: - font.setStyle(QFont.StyleItalic) + font.setStyle(QFont.Style.StyleItalic) elif self.fontStyle == 4: - font.setWeight(QFont.Bold) - font.setStyle(QFont.StyleItalic) + font.setWeight(QFont.Weight.Bold) + font.setStyle(QFont.Style.StyleItalic) elif self.fontStyle == 5: - font.setStyle(QFont.StyleOblique) + font.setStyle(QFont.Style.StyleOblique) elif self.fontStyle == 6: - font.setCapitalization(QFont.SmallCaps) + font.setCapitalization(QFont.Capitalization.SmallCaps) image = FramePainter(width, height) x, y = self.getXY() - log.debug('Text position translates to %s, %s', x, y) + log.debug("Text position translates to %s, %s", x, y) if self.stroke > 0: outliner = QtGui.QPainterPathStroker() outliner.setWidth(self.stroke) @@ -149,16 +163,16 @@ class Component(Component): # PathStroker ignores smallcaps so we need this weird hack path.addText(x, y, font, self.title[0]) fm = QtGui.QFontMetrics(font) - newX = x + fm.width(self.title[0]) + newX = x + fm.boundingRect(self.title[0]).width() strokeFont = self.page.fontComboBox_titleFont.currentFont() - strokeFont.setCapitalization(QFont.SmallCaps) + strokeFont.setCapitalization(QFont.Capitalization.SmallCaps) strokeFont.setPixelSize(int((self.fontSize / 7) * 5)) - strokeFont.setLetterSpacing(QFont.PercentageSpacing, 139) + strokeFont.setLetterSpacing(QFont.SpacingType.PercentageSpacing, 139) path.addText(newX, y, strokeFont, self.title[1:]) else: path.addText(x, y, font, self.title) path = outliner.createStroke(path) - image.setPen(QtCore.Qt.NoPen) + image.setPen(QtCore.Qt.PenStyle.NoPen) image.setBrush(PaintColor(*self.strokeColor)) image.drawPath(path) @@ -178,27 +192,27 @@ class Component(Component): return frame def commandHelp(self): - print('Enter a string to use as centred white text:') + print("Enter a string to use as centred white text:") print(' "title=User Error"') - print('Specify a text color:\n color=255,255,255') - print('Set custom x, y position:\n x=500 y=500') + print("Specify a text color:\n color=255,255,255") + print("Set custom x, y position:\n x=500 y=500") def command(self, arg): - if '=' in arg: - key, arg = arg.split('=', 1) - if key == 'color': + if "=" in arg: + key, arg = arg.split("=", 1) + if key == "color": self.page.lineEdit_textColor.setText(arg) return - elif key == 'size': + elif key == "size": self.page.spinBox_fontSize.setValue(int(arg)) return - elif key == 'x': + elif key == "x": self.page.spinBox_xTextAlign.setValue(int(arg)) return - elif key == 'y': + elif key == "y": self.page.spinBox_yTextAlign.setValue(int(arg)) return - elif key == 'title': + elif key == "title": self.page.lineEdit_title.setText(arg) return super().command(arg) diff --git a/src/components/video.py b/src/components/video.py index 60ca800..65a05af 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -1,5 +1,5 @@ from PIL import Image -from PyQt5 import QtGui, QtCore, QtWidgets +from PyQt6 import QtGui, QtCore, QtWidgets import os import math import subprocess @@ -11,15 +11,15 @@ from ..toolkit.ffmpeg import openPipe, closePipe, testAudioStream, FfmpegVideo from ..toolkit import checkOutput -log = logging.getLogger('AVP.Components.Video') +log = logging.getLogger("AVP.Components.Video") class Component(Component): - name = 'Video' - version = '1.0.0' + name = "Video" + version = "1.0.0" def widget(self, *args): - self.videoPath = '' + self.videoPath = "" self.badAudio = False self.x = 0 self.y = 0 @@ -27,23 +27,28 @@ class Component(Component): super().widget(*args) self._image = BlankFrame(self.width, self.height) 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', - }, relativeWidgets=[ - 'xPosition', 'yPosition', - ]) + 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", + }, + relativeWidgets=[ + "xPosition", + "yPosition", + ], + ) def update(self): if self.page.checkBox_useAudio.isChecked(): @@ -64,7 +69,7 @@ class Component(Component): def properties(self): props = [] outputFile = None - if hasattr(self.parent, 'lineEdit_outputFile'): + if hasattr(self.parent, "lineEdit_outputFile"): # check only happens in GUI mode outputFile = self.parent.lineEdit_outputFile.text() @@ -72,34 +77,42 @@ class Component(Component): self.lockError("There is no video selected.") elif not os.path.exists(self.videoPath): self.lockError("The video selected does not exist!") - elif outputFile and os.path.realpath(self.videoPath) == os.path.realpath(outputFile): + elif outputFile and os.path.realpath(self.videoPath) == os.path.realpath( + outputFile + ): self.lockError("Input and output paths match.") if self.useAudio: - props.append('audio') - if not testAudioStream(self.videoPath) \ - and self.error() is None: - self.lockError( - "Could not identify an audio stream in this video.") + props.append("audio") + if not testAudioStream(self.videoPath) and self.error() is None: + self.lockError("Could not identify an audio stream in this video.") return props def audio(self): params = {} if self.volume != 1.0: - params['volume'] = '=%s:replaygain_noclip=0' % str(self.volume) + params["volume"] = "=%s:replaygain_noclip=0" % str(self.volume) return (self.videoPath, params) def preFrameRender(self, **kwargs): super().preFrameRender(**kwargs) self.updateChunksize() - self.video = FfmpegVideo( - inputPath=self.videoPath, filter_=self.makeFfmpegFilter(), - width=self.width, height=self.height, chunkSize=self.chunkSize, - frameRate=int(self.settings.value("outputFrameRate")), - parent=self.parent, loopVideo=self.loopVideo, - component=self - ) if os.path.exists(self.videoPath) else None + self.video = ( + FfmpegVideo( + inputPath=self.videoPath, + filter_=self.makeFfmpegFilter(), + width=self.width, + height=self.height, + chunkSize=self.chunkSize, + frameRate=int(self.settings.value("outputFrameRate")), + parent=self.parent, + loopVideo=self.loopVideo, + component=self, + ) + if os.path.exists(self.videoPath) + else None + ) def frameRender(self, frameNo): if FfmpegVideo.threadError is not None: @@ -112,8 +125,10 @@ class Component(Component): def pickVideo(self): imgDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( - self.page, "Choose Video", - imgDir, "Video Files (%s)" % " ".join(self.core.videoFormats) + self.page, + "Choose Video", + imgDir, + "Video Files (%s)" % " ".join(self.core.videoFormats), ) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) @@ -127,33 +142,50 @@ class Component(Component): command = [ self.core.FFMPEG_BIN, - '-thread_queue_size', '512', - '-i', self.videoPath, - '-f', 'image2pipe', - '-pix_fmt', 'rgba', + "-thread_queue_size", + "512", + "-i", + self.videoPath, + "-f", + "image2pipe", + "-pix_fmt", + "rgba", ] command.extend(self.makeFfmpegFilter()) - command.extend([ - '-codec:v', 'rawvideo', '-', - '-ss', '90', - '-frames:v', '1', - ]) + command.extend( + [ + "-codec:v", + "rawvideo", + "-", + "-ss", + "90", + "-frames:v", + "1", + ] + ) if self.core.logEnabled: logFilename = os.path.join( - self.core.logDir, 'preview_%s.log' % str(self.compPos)) - log.debug('Creating ffmpeg process (log at %s)' % logFilename) - with open(logFilename, 'w') as logf: - logf.write(" ".join(command) + '\n\n') - with open(logFilename, 'a') as logf: + self.core.logDir, "preview_%s.log" % str(self.compPos) + ) + log.debug("Creating ffmpeg process (log at %s)" % logFilename) + with open(logFilename, "w") as logf: + logf.write(" ".join(command) + "\n\n") + with open(logFilename, "a") as logf: pipe = openPipe( - command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, - stderr=logf, bufsize=10**8 + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=logf, + bufsize=10**8, ) else: pipe = openPipe( - command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, bufsize=10**8 + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=10**8, ) byteFrame = pipe.stdout.read(self.chunkSize) @@ -164,9 +196,8 @@ class Component(Component): def makeFfmpegFilter(self): return [ - '-filter_complex', - '[0:v] scale=%s:%s' % scale( - self.scale, self.width, self.height, str), + "-filter_complex", + "[0:v] scale=%s:%s" % scale(self.scale, self.width, self.height, str), ] def updateChunksize(self): @@ -177,10 +208,10 @@ class Component(Component): self.chunkSize = 4 * width * height def command(self, 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 self.core.videoFormats: + if "=" in arg: + key, arg = arg.split("=", 1) + if key == "path" and os.path.exists(arg): + if "*%s" % os.path.splitext(arg)[1] in self.core.videoFormats: self.page.lineEdit_video.setText(arg) self.page.spinBox_scale.setValue(100) self.page.checkBox_loop.setChecked(True) @@ -188,7 +219,7 @@ class Component(Component): else: print("Not a supported video format") quit(1) - elif arg == 'audio': + elif arg == "audio": if not self.page.lineEdit_video.text(): print("'audio' option must follow a video selection") quit(1) @@ -197,28 +228,25 @@ class Component(Component): super().command(arg) def commandHelp(self): - print('Load a video:\n path=/filepath/to/video.mp4') - print('Using audio:\n path=/filepath/to/video.mp4 audio') + print("Load a video:\n path=/filepath/to/video.mp4") + print("Using audio:\n path=/filepath/to/video.mp4 audio") def finalizeFrame(self, imageData): try: if self.distort: - image = Image.frombytes( - 'RGBA', - (self.width, self.height), - imageData) + image = Image.frombytes("RGBA", (self.width, self.height), imageData) else: image = Image.frombytes( - 'RGBA', + "RGBA", scale(self.scale, self.width, self.height, int), - imageData) + imageData, + ) self._image = image except ValueError: # use last good frame image = self._image - if self.scale != 100 \ - or self.xPosition != 0 or self.yPosition != 0: + if self.scale != 100 or self.xPosition != 0 or self.yPosition != 0: frame = BlankFrame(self.width, self.height) frame.paste(image, box=(self.xPosition, self.yPosition)) else: diff --git a/src/components/waveform.py b/src/components/waveform.py index eef6de0..7dc0b99 100644 --- a/src/components/waveform.py +++ b/src/components/waveform.py @@ -1,6 +1,6 @@ from PIL import Image -from PyQt5 import QtGui, QtCore, QtWidgets -from PyQt5.QtGui import QColor +from PyQt6 import QtGui, QtCore, QtWidgets +from PyQt6.QtGui import QColor import os import math import subprocess @@ -10,44 +10,51 @@ from ..component import Component from ..toolkit.frame import BlankFrame, scale from ..toolkit import checkOutput from ..toolkit.ffmpeg import ( - openPipe, closePipe, getAudioDuration, FfmpegVideo, exampleSound + openPipe, + closePipe, + getAudioDuration, + FfmpegVideo, + exampleSound, ) -log = logging.getLogger('AVP.Components.Waveform') +log = logging.getLogger("AVP.Components.Waveform") class Component(Component): - name = 'Waveform' - version = '1.0.0' + name = "Waveform" + version = "1.0.0" def widget(self, *args): super().widget(*args) self._image = BlankFrame(self.width, self.height) - self.page.lineEdit_color.setText('255,255,255') - - if hasattr(self.parent, 'lineEdit_audioFile'): - self.parent.lineEdit_audioFile.textChanged.connect( - self.update - ) - - self.trackWidgets({ - 'color': self.page.lineEdit_color, - 'mode': self.page.comboBox_mode, - 'amplitude': self.page.comboBox_amplitude, - 'x': self.page.spinBox_x, - 'y': self.page.spinBox_y, - 'mirror': self.page.checkBox_mirror, - 'scale': self.page.spinBox_scale, - 'opacity': self.page.spinBox_opacity, - 'compress': self.page.checkBox_compress, - 'mono': self.page.checkBox_mono, - }, colorWidgets={ - 'color': self.page.pushButton_color, - }, relativeWidgets=[ - 'x', 'y', - ]) + self.page.lineEdit_color.setText("255,255,255") + + if hasattr(self.parent, "lineEdit_audioFile"): + self.parent.lineEdit_audioFile.textChanged.connect(self.update) + + self.trackWidgets( + { + "color": self.page.lineEdit_color, + "mode": self.page.comboBox_mode, + "amplitude": self.page.comboBox_amplitude, + "x": self.page.spinBox_x, + "y": self.page.spinBox_y, + "mirror": self.page.checkBox_mirror, + "scale": self.page.spinBox_scale, + "opacity": self.page.spinBox_opacity, + "compress": self.page.checkBox_compress, + "mono": self.page.checkBox_mono, + }, + colorWidgets={ + "color": self.page.pushButton_color, + }, + relativeWidgets=[ + "x", + "y", + ], + ) def previewRender(self): self.updateChunksize() @@ -64,10 +71,13 @@ class Component(Component): self.video = FfmpegVideo( inputPath=self.audioFile, filter_=self.makeFfmpegFilter(), - width=w, height=h, + width=w, + height=h, chunkSize=self.chunkSize, frameRate=int(self.settings.value("outputFrameRate")), - parent=self.parent, component=self, debug=True, + parent=self.parent, + component=self, + debug=True, ) def frameRender(self, frameNo): @@ -94,37 +104,54 @@ class Component(Component): command = [ self.core.FFMPEG_BIN, - '-thread_queue_size', '512', - '-r', str(self.settings.value("outputFrameRate")), - '-ss', "{0:.3f}".format(startPt), - '-i', - self.core.junkStream - if genericPreview else inputFile, - '-f', 'image2pipe', - '-pix_fmt', 'rgba', + "-thread_queue_size", + "512", + "-r", + str(self.settings.value("outputFrameRate")), + "-ss", + "{0:.3f}".format(startPt), + "-i", + self.core.junkStream if genericPreview else inputFile, + "-f", + "image2pipe", + "-pix_fmt", + "rgba", ] command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt)) - command.extend([ - '-an', - '-s:v', '%sx%s' % scale(self.scale, self.width, self.height, str), - '-codec:v', 'rawvideo', '-', - '-frames:v', '1', - ]) + command.extend( + [ + "-an", + "-s:v", + "%sx%s" % scale(self.scale, self.width, self.height, str), + "-codec:v", + "rawvideo", + "-", + "-frames:v", + "1", + ] + ) if self.core.logEnabled: logFilename = os.path.join( - self.core.logDir, 'preview_%s.log' % str(self.compPos)) - log.debug('Creating ffmpeg log at %s', logFilename) - with open(logFilename, 'w') as logf: - logf.write(" ".join(command) + '\n\n') - with open(logFilename, 'a') as logf: + self.core.logDir, "preview_%s.log" % str(self.compPos) + ) + log.debug("Creating ffmpeg log at %s", logFilename) + with open(logFilename, "w") as logf: + logf.write(" ".join(command) + "\n\n") + with open(logFilename, "a") as logf: pipe = openPipe( - command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, - stderr=logf, bufsize=10**8 + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=logf, + bufsize=10**8, ) else: pipe = openPipe( - command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, bufsize=10**8 + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=10**8, ) byteFrame = pipe.stdout.read(self.chunkSize) closePipe(pipe) @@ -135,35 +162,35 @@ class Component(Component): def makeFfmpegFilter(self, preview=False, startPt=0): w, h = scale(self.scale, self.width, self.height, str) if self.amplitude == 0: - amplitude = 'lin' + amplitude = "lin" elif self.amplitude == 1: - amplitude = 'log' + amplitude = "log" elif self.amplitude == 2: - amplitude = 'sqrt' + amplitude = "sqrt" elif self.amplitude == 3: - amplitude = 'cbrt' + amplitude = "cbrt" hexcolor = QColor(*self.color).name() opacity = "{0:.1f}".format(self.opacity / 100) genericPreview = self.settings.value("pref_genericPreview") if self.mode < 3: filter_ = ( - 'showwaves=' + "showwaves=" f'r={str(self.settings.value("outputFrameRate"))}:' f's={self.settings.value("outputWidth")}x{self.settings.value("outputHeight")}:' f'mode={self.page.comboBox_mode.currentText().lower() if self.mode != 3 else "p2p"}:' - f'colors={hexcolor}@{opacity}:scale={amplitude}' + f"colors={hexcolor}@{opacity}:scale={amplitude}" ) elif self.mode > 2: filter_ = ( f'showfreqs=s={str(self.settings.value("outputWidth"))}x{str(self.settings.value("outputHeight"))}:' f'mode={"line" if self.mode == 4 else "bar"}:' - f'colors={hexcolor}@{opacity}' + f"colors={hexcolor}@{opacity}" f":ascale={amplitude}:fscale={'log' if self.mono else 'lin'}" ) baselineHeight = int(self.height * (4 / 1080)) return [ - '-filter_complex', + "-filter_complex", f"{exampleSound('wave', extra='') if preview and genericPreview else '[0:a] '}" f"{'compand=gain=4,' if self.compress else ''}" f"{'aformat=channel_layouts=mono,' if self.mono and self.mode < 3 else ''}" @@ -171,12 +198,14 @@ class Component(Component): f"{', drawbox=x=(iw-w)/2:y=(ih-h)/2:w=iw:h=%s:color=%s@%s' % (baselineHeight, hexcolor, opacity) if self.mode < 2 else ''}" f"{', hflip' if self.mirror else''}" " [v1]; " - '[v1] scale=%s:%s%s [v]' % ( - w, h, - ', trim=duration=%s' % "{0:.3f}".format(startPt + 3) - if preview else '', + "[v1] scale=%s:%s%s [v]" + % ( + w, + h, + ", trim=duration=%s" % "{0:.3f}".format(startPt + 3) if preview else "", ), - '-map', '[v]', + "-map", + "[v]", ] def updateChunksize(self): @@ -186,15 +215,14 @@ class Component(Component): def finalizeFrame(self, imageData): try: image = Image.frombytes( - 'RGBA', + "RGBA", scale(self.scale, self.width, self.height, int), - imageData + imageData, ) self._image = image except ValueError: image = self._image - if self.scale != 100 \ - or self.x != 0 or self.y != 0: + if self.scale != 100 or self.x != 0 or self.y != 0: frame = BlankFrame(self.width, self.height) frame.paste(image, box=(self.x, self.y)) else: diff --git a/src/core.py b/src/core.py index 1ad4a67..df6ff63 100644 --- a/src/core.py +++ b/src/core.py @@ -1,8 +1,9 @@ -''' - 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 +""" +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 PyQt6 import QtCore, QtGui, uic import sys import os import json @@ -12,20 +13,20 @@ import logging from . import toolkit -log = logging.getLogger('AVP.Core') +log = logging.getLogger("AVP.Core") STDOUT_LOGLVL = logging.WARNING FILE_LIBLOGLVL = logging.WARNING FILE_LOGLVL = logging.INFO 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, handles opening/creating project files - and presets, and creates the video thread to export. - This class also stores constants as class variables. - ''' + """ + 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, handles opening/creating project files + and presets, and creates the video thread to export. + This class also stores constants as class variables. + """ def __init__(self): self.importComponents() @@ -34,9 +35,7 @@ class Core: self.openingProject = False def __repr__(self): - return "\n=~=~=~=\n".join( - [repr(comp) for comp in self.selectedComponents] - ) + return "\n=~=~=~=\n".join([repr(comp) for comp in self.selectedComponents]) def importComponents(self): def findComponents(): @@ -44,11 +43,12 @@ class Core: name, ext = os.path.splitext(f) if name.startswith("__"): continue - elif ext == '.py': + elif ext == ".py": yield name - log.debug('Importing component modules') + + log.debug("Importing component modules") self.modules = [ - import_module('.components.%s' % name, __package__) + import_module(".components.%s" % name, __package__) for name in findComponents() ] # store canonical module names and indexes @@ -62,7 +62,7 @@ class Core: # store alternative names for modules self.altCompNames = [] for i, mod in enumerate(self.modules): - if hasattr(mod.Component, 'names'): + if hasattr(mod.Component, "names"): for name in mod.Component.names(): self.altCompNames.append((name, i)) @@ -71,10 +71,10 @@ class Core: component.compPos = i def insertComponent(self, compPos, component, loader): - ''' - Creates a new component using these args: - (compPos, component obj or moduleIndex, MWindow/Command/Core obj) - ''' + """ + Creates a new component using these args: + (compPos, component obj or moduleIndex, MWindow/Command/Core obj) + """ if compPos < 0 or compPos > len(self.selectedComponents): compPos = len(self.selectedComponents) if len(self.selectedComponents) > 50: @@ -82,25 +82,16 @@ class Core: if type(component) is int: # create component using module index in self.modules moduleIndex = int(component) - log.debug( - 'Creating new component from module #%s', str(moduleIndex)) - component = self.modules[moduleIndex].Component( - moduleIndex, compPos, self - ) + log.debug("Creating new component from module #%s", str(moduleIndex)) + component = self.modules[moduleIndex].Component(moduleIndex, compPos, self) component.widget(loader) else: moduleIndex = -1 - log.debug( - 'Inserting previously-created %s component', component.name) + log.debug("Inserting previously-created %s component", component.name) - component._error.connect( - loader.videoThreadError - ) - self.selectedComponents.insert( - compPos, - component - ) - if hasattr(loader, 'insertComponent'): + component._error.connect(loader.videoThreadError) + self.selectedComponents.insert(compPos, component) + if hasattr(loader, "insertComponent"): loader.insertComponent(compPos) self.componentListChanged() @@ -123,9 +114,7 @@ class Core: self.componentListChanged() def updateComponent(self, i): - log.debug( - 'Auto-updating %s #%s', - self.selectedComponents[i], str(i)) + log.debug("Auto-updating %s #%s", self.selectedComponents[i], str(i)) self.selectedComponents[i].update(auto=True) def moduleIndexFor(self, compName): @@ -141,63 +130,59 @@ class Core: self.selectedComponents[compIndex].currentPreset = None def openPreset(self, filepath, compIndex, presetName): - '''Applies a preset to a specific component''' + """Applies a preset to a specific component""" saveValueStore = self.getPreset(filepath) if not saveValueStore: return False comp = self.selectedComponents[compIndex] - comp.loadPreset( - saveValueStore, - presetName - ) + comp.loadPreset(saveValueStore, presetName) self.savedPresets[presetName] = dict(saveValueStore) return True def getPreset(self, filepath): - '''Returns the preset dict stored at this filepath''' + """Returns the preset dict stored at this filepath""" if not os.path.exists(filepath): return False - with open(filepath, 'r') as f: + with open(filepath, "r") as f: for line in f: saveValueStore = toolkit.presetFromString(line.strip()) break return saveValueStore def getPresetDir(self, comp): - '''Get the preset subdir for a particular version of a component''' + """Get the preset subdir for a particular version of a component""" return os.path.join(Core.presetDir, comp.name, str(comp.version)) def openProject(self, loader, filepath): - ''' loader is the object calling this method which must have + """loader is the object calling this method which must have its own showMessage(**kwargs) method for displaying errors. - ''' + """ if not os.path.exists(filepath): - loader.showMessage(msg='Project file not found.') + loader.showMessage(msg="Project file not found.") return errcode, data = self.parseAvFile(filepath) if errcode == 0: self.openingProject = True try: - if hasattr(loader, 'window'): - for widget, value in data['WindowFields']: - widget = eval('loader.%s' % widget) + if hasattr(loader, "window"): + for widget, value in data["WindowFields"]: + widget = eval("loader.%s" % widget) with toolkit.blockSignals(widget): toolkit.setWidgetValue(widget, value) - for key, value in data['Settings']: + for key, value in data["Settings"]: Core.settings.setValue(key, value) - for tup in data['Components']: + for tup in data["Components"]: name, vers, preset = tup clearThis = False modified = False # add loaded named presets to savedPresets dict - if 'preset' in preset and preset['preset'] is not None: - nam = preset['preset'] - filepath2 = os.path.join( - Core.presetDir, name, str(vers), nam) + if "preset" in preset and preset["preset"] is not None: + nam = preset["preset"] + filepath2 = os.path.join(Core.presetDir, name, str(vers), nam) origSaveValueStore = self.getPreset(filepath2) if origSaveValueStore: self.savedPresets[nam] = dict(origSaveValueStore) @@ -207,33 +192,31 @@ class Core: clearThis = True # create the actual component object & get its index - i = self.insertComponent( - -1, - self.moduleIndexFor(name), - loader - ) + i = self.insertComponent(-1, self.moduleIndexFor(name), loader) + if i is None: + loader.showMessage( + msg=f"Component '{name}' didn't initialize correctly and had to be removed." + ) + continue if i == -1: loader.showMessage(msg="Too many components!") break try: - if 'preset' in preset and preset['preset'] is not None: - self.selectedComponents[i].loadPreset( - preset - ) + if "preset" in preset and preset["preset"] is not None: + self.selectedComponents[i].loadPreset(preset) else: self.selectedComponents[i].loadPreset( - preset, - preset['preset'] + preset, preset["preset"] ) except KeyError as e: - log.warning('%s missing value: %s' % ( - self.selectedComponents[i], e) + log.warning( + "%s missing value: %s" % (self.selectedComponents[i], e) ) if clearThis: self.clearPreset(i) - if hasattr(loader, 'updateComponentTitle'): + if hasattr(loader, "updateComponentTitle"): loader.updateComponentTitle(i, modified) self.openingProject = False return True @@ -243,56 +226,57 @@ class Core: if errcode == 1: typ, value, tb = data - if typ.__name__ == 'KeyError': + if typ.__name__ == "KeyError": # probably just an old version, still loadable - log.warning('Project file missing value: %s' % value) + log.warning("Project file missing value: %s" % value) return - if hasattr(loader, 'createNewProject'): + if hasattr(loader, "createNewProject"): loader.createNewProject(prompt=False) - msg = '%s: %s\n\n' % (typ.__name__, value) + msg = "%s: %s\n\n" % (typ.__name__, value) msg += toolkit.formatTraceback(tb) loader.showMessage( msg="Project file '%s' is corrupted." % filepath, showCancel=False, - icon='Warning', - detail=msg) + icon="Warning", + detail=msg, + ) self.openingProject = False return 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) - ''' - log.debug('Parsing av file: %s', filepath) - validSections = ( - 'Components', - 'Settings', - 'WindowFields' - ) + """ + 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) + """ + log.debug("Parsing av file: %s", filepath) + validSections = ("Components", "Settings", "WindowFields") data = {sect: [] for sect in validSections} try: - with open(filepath, 'r') as f: + with open(filepath, "r") as f: + def parseLine(line): - '''Decides if a file line is a section header''' + """Decides if a file line is a section header""" line = line.strip() - newSection = '' + newSection = "" - if line.startswith('[') and line.endswith(']') \ - and line[1:-1] in validSections: + if ( + line.startswith("[") + and line.endswith("]") + and line[1:-1] in validSections + ): newSection = line[1:-1] return line, newSection - section = '' + section = "" i = 0 for line in f: line, newSection = parseLine(line) if newSection: section = str(newSection) continue - if line and section == 'Components': + if line and section == "Components": if i == 0: lastCompName = str(line) i += 1 @@ -301,14 +285,12 @@ class Core: i += 1 elif i == 2: lastCompPreset = toolkit.presetFromString(line) - data[section].append(( - lastCompName, - lastCompVers, - lastCompPreset - )) + data[section].append( + (lastCompName, lastCompVers, lastCompPreset) + ) i = 0 elif line and section: - key, value = line.split('=', 1) + key, value = line.split("=", 1) data[section].append((key, value.strip())) return 0, data @@ -319,51 +301,40 @@ class Core: errcode, data = self.parseAvFile(filepath) returnList = [] if errcode == 0: - name, vers, preset = data['Components'][0] - presetName = preset['preset'] \ - if preset['preset'] else os.path.basename(filepath)[:-4] - newPath = os.path.join( - Core.presetDir, - name, - vers, - presetName + name, vers, preset = data["Components"][0] + presetName = ( + preset["preset"] + if preset["preset"] + else os.path.basename(filepath)[:-4] ) + newPath = os.path.join(Core.presetDir, name, vers, presetName) if os.path.exists(newPath): return False, newPath - preset['preset'] = presetName - self.createPresetFile( - name, vers, presetName, preset - ) + preset["preset"] = presetName + self.createPresetFile(name, vers, presetName, preset) return True, presetName elif errcode == 1: # TODO: an error message - return False, '' + return False, "" def exportPreset(self, exportPath, compName, vers, origName): - internalPath = os.path.join( - Core.presetDir, compName, str(vers), origName - ) + internalPath = os.path.join(Core.presetDir, compName, str(vers), origName) if not os.path.exists(internalPath): return if os.path.exists(exportPath): os.remove(exportPath) - with open(internalPath, 'r') as f: + with open(internalPath, "r") as f: internalData = [line for line in f] try: saveValueStore = toolkit.presetFromString(internalData[0].strip()) - self.createPresetFile( - compName, vers, - origName, saveValueStore, - exportPath - ) + self.createPresetFile(compName, vers, origName, saveValueStore, exportPath) return True except Exception: return False - def createPresetFile( - self, compName, vers, presetName, saveValueStore, filepath=''): - '''Create a preset file (.avl) at filepath using args. - Or if filepath is empty, create an internal preset using args''' + def createPresetFile(self, compName, vers, presetName, saveValueStore, filepath=""): + """Create a preset file (.avl) at filepath using args. + Or if filepath is empty, create an internal preset using args""" if not filepath: dirname = os.path.join(Core.presetDir, compName, str(vers)) if not os.path.exists(dirname): @@ -371,54 +342,55 @@ class Core: filepath = os.path.join(dirname, presetName) internal = True else: - if not filepath.endswith('.avl'): - filepath += '.avl' + if not filepath.endswith(".avl"): + filepath += ".avl" internal = False - with open(filepath, 'w') as f: + with open(filepath, "w") as f: if not internal: - f.write('[Components]\n') - f.write('%s\n' % compName) - f.write('%s\n' % str(vers)) + f.write("[Components]\n") + f.write("%s\n" % compName) + f.write("%s\n" % str(vers)) f.write(toolkit.presetToString(saveValueStore)) def createProjectFile(self, filepath, window=None): - '''Create a project file (.avp) using the current program state''' - log.info('Creating %s', filepath) + """Create a project file (.avp) using the current program state""" + log.info("Creating %s", filepath) settingsKeys = [ - 'componentDir', - 'inputDir', - 'outputDir', - 'presetDir', - 'projectDir', + "componentDir", + "inputDir", + "outputDir", + "presetDir", + "projectDir", ] try: if not filepath.endswith(".avp"): - filepath += '.avp' + filepath += ".avp" if os.path.exists(filepath): os.remove(filepath) - with open(filepath, 'w') as f: - f.write('[Components]\n') + with open(filepath, "w") as f: + f.write("[Components]\n") for comp in self.selectedComponents: saveValueStore = comp.savePreset() - saveValueStore['preset'] = comp.currentPreset - f.write('%s\n' % str(comp)) - f.write('%s\n' % str(comp.version)) - f.write('%s\n' % toolkit.presetToString(saveValueStore)) + saveValueStore["preset"] = comp.currentPreset + f.write("%s\n" % str(comp)) + f.write("%s\n" % str(comp.version)) + f.write("%s\n" % toolkit.presetToString(saveValueStore)) - f.write('\n[Settings]\n') + f.write("\n[Settings]\n") for key in Core.settings.allKeys(): if key in settingsKeys: - f.write('%s=%s\n' % (key, Core.settings.value(key))) + f.write("%s=%s\n" % (key, Core.settings.value(key))) if window: - f.write('\n[WindowFields]\n') + f.write("\n[WindowFields]\n") f.write( - 'lineEdit_audioFile=%s\n' - 'lineEdit_outputFile=%s\n' % ( + "lineEdit_audioFile=%s\n" + "lineEdit_outputFile=%s\n" + % ( window.lineEdit_audioFile.text(), - window.lineEdit_outputFile.text() + window.lineEdit_outputFile.text(), ) ) return True @@ -426,8 +398,9 @@ class Core: return False def newVideoWorker(self, loader, audioFile, outputPath): - '''loader is MainWindow or Command object which must own the thread''' + """loader is MainWindow or Command object which must own the thread""" from . import video_thread + self.videoThread = QtCore.QThread(loader) videoWorker = video_thread.Worker( loader, audioFile, outputPath, self.selectedComponents @@ -450,18 +423,18 @@ class Core: @classmethod def storeSettings(cls): - '''Store settings/paths to directories as class variables''' + """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 + QtCore.QStandardPaths.StandardLocation.AppConfigLocation ) # Windows: C:/Users/<USER>/AppData/Local/audio-visualizer # macOS: ~/Library/Preferences/audio-visualizer # Linux: ~/.config/audio-visualizer - with open(os.path.join(wd, 'encoder-options.json')) as json_file: + with open(os.path.join(wd, "encoder-options.json")) as json_file: encoderOptions = json.load(json_file) # Locate FFmpeg @@ -470,53 +443,60 @@ class Core: print("Could not find FFmpeg") settings = { - 'canceled': False, - 'FFMPEG_BIN': ffmpegBin, - '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'), - 'junkStream': os.path.join(wd, 'gui', 'background.png'), - 'encoderOptions': encoderOptions, - 'resolutions': [ - '1920x1080', - '1280x720', - '854x480', + "canceled": False, + "FFMPEG_BIN": ffmpegBin, + "dataDir": dataDir, + "settings": QtCore.QSettings( + os.path.join(dataDir, "settings.ini"), + QtCore.QSettings.Format.IniFormat, + ), + "presetDir": os.path.join(dataDir, "presets"), + "componentsPath": os.path.join(wd, "components"), + "junkStream": os.path.join(wd, "gui", "background.png"), + "encoderOptions": encoderOptions, + "resolutions": [ + "1920x1080", + "1280x720", + "854x480", ], - 'logDir': os.path.join(dataDir, 'log'), - 'logEnabled': False, - 'previewEnabled': True, + "logDir": os.path.join(dataDir, "log"), + "logEnabled": False, + "previewEnabled": True, } - 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', - ]) + 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(): @@ -526,7 +506,10 @@ class Core: if not os.path.exists(cls.dataDir): os.makedirs(cls.dataDir) for neededDirectory in ( - cls.presetDir, cls.logDir, cls.settings.value("projectDir")): + cls.presetDir, + cls.logDir, + cls.settings.value("projectDir"), + ): if not os.path.exists(neededDirectory): os.mkdir(neededDirectory) cls.makeLogger(deleteOldLogs=True) @@ -546,7 +529,7 @@ class Core: "outputPreset": "medium", "outputFormat": "mp4", "outputContainer": "MP4", - "projectDir": os.path.join(cls.dataDir, 'projects'), + "projectDir": os.path.join(cls.dataDir, "projects"), "pref_insertCompAtTop": True, "pref_genericPreview": True, "pref_undoLimit": 10, @@ -559,15 +542,15 @@ class Core: # 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_'): + if not key.startswith("pref_"): continue val = cls.settings.value(key) try: val = int(val) except ValueError: - if val == 'true': + if val == "true": val = True - elif val == 'false': + elif val == "false": val = False cls.settings.setValue(key, val) @@ -576,18 +559,16 @@ class Core: # send critical log messages to stdout logStream = logging.StreamHandler() logStream.setLevel(STDOUT_LOGLVL) - streamFormatter = logging.Formatter( - '<%(name)s> %(levelname)s: %(message)s' - ) + streamFormatter = logging.Formatter("<%(name)s> %(levelname)s: %(message)s") logStream.setFormatter(streamFormatter) - log = logging.getLogger('AVP') + log = logging.getLogger("AVP") log.addHandler(logStream) if FILE_LOGLVL is not None: # write log files as well! Core.logEnabled = True - logFilename = os.path.join(Core.logDir, 'avp_debug.log') - libLogFilename = os.path.join(Core.logDir, 'global_debug.log') + logFilename = os.path.join(Core.logDir, "avp_debug.log") + libLogFilename = os.path.join(Core.logDir, "global_debug.log") if deleteOldLogs: for log_ in (logFilename, libLogFilename): @@ -599,8 +580,8 @@ class Core: libLogFile = logging.FileHandler(libLogFilename, delay=True) libLogFile.setLevel(FILE_LIBLOGLVL) fileFormatter = logging.Formatter( - '[%(asctime)s] %(threadName)-10.10s %(name)-23.23s %(levelname)s: ' - '%(message)s' + "[%(asctime)s] %(threadName)-10.10s %(name)-23.23s %(levelname)s: " + "%(message)s" ) logFile.setFormatter(fileFormatter) libLogFile.setFormatter(fileFormatter) @@ -611,5 +592,6 @@ class Core: # lowest level must be explicitly set on the root Logger libLog.setLevel(0) + # always store settings in class variables even if a Core object is not created Core.storeSettings() diff --git a/src/gui/actions.py b/src/gui/actions.py index afb980a..654b2a0 100644 --- a/src/gui/actions.py +++ b/src/gui/actions.py @@ -1,53 +1,61 @@ -''' - QCommand classes for every undoable user action performed in the MainWindow -''' -from PyQt5.QtWidgets import QUndoCommand +""" +QCommand classes for every undoable user action performed in the MainWindow +""" + +from PyQt6.QtGui import QUndoCommand import os +import logging from copy import copy from ..core import Core +log = logging.getLogger("AVP.Gui.Actions") + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ # COMPONENT ACTIONS # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + class AddComponent(QUndoCommand): def __init__(self, parent, compI, moduleI): super().__init__( - "create new %s component" % - parent.core.modules[moduleI].Component.name + "create new %s component" % parent.core.modules[moduleI].Component.name ) self.parent = parent self.moduleI = moduleI self.compI = compI self.comp = None + self.valid = True def redo(self): if self.comp is None: - self.parent.core.insertComponent( - self.compI, self.moduleI, self.parent) + i = self.parent.core.insertComponent(self.compI, self.moduleI, self.parent) + if i != self.compI: + self.valid = False + if i is not None: + log.error( + f"Expected new component index to be {self.compI} but received {i}" + ) else: # inserting previously-created component - self.parent.core.insertComponent( - self.compI, self.comp, self.parent) + self.parent.core.insertComponent(self.compI, self.comp, self.parent) def undo(self): + if not self.valid: + return self.comp = self.parent.core.selectedComponents[self.compI] self.parent._removeComponent(self.compI) class RemoveComponent(QUndoCommand): def __init__(self, parent, selectedRows): - super().__init__('remove component') + super().__init__("remove component") self.parent = parent componentList = self.parent.listWidget_componentList - self.selectedRows = [ - componentList.row(selected) for selected in selectedRows - ] - self.components = [ - parent.core.selectedComponents[i] for i in self.selectedRows - ] + self.selectedRows = [componentList.row(selected) for selected in selectedRows] + self.components = [parent.core.selectedComponents[i] for i in self.selectedRows] def redo(self): self.parent._removeComponent(self.selectedRows[0]) @@ -55,9 +63,7 @@ class RemoveComponent(QUndoCommand): def undo(self): componentList = self.parent.listWidget_componentList for index, comp in zip(self.selectedRows, self.components): - self.parent.core.insertComponent( - index, comp, self.parent - ) + self.parent.core.insertComponent(index, comp, self.parent) self.parent.drawPreview() @@ -70,7 +76,7 @@ class MoveComponent(QUndoCommand): self.id_ = ord(tag[0]) def id(self): - '''If 2 consecutive updates have same id, Qt will call mergeWith()''' + """If 2 consecutive updates have same id, Qt will call mergeWith()""" return self.id_ def mergeWith(self, other): @@ -105,6 +111,7 @@ class MoveComponent(QUndoCommand): # PRESET ACTIONS # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + class ClearPreset(QUndoCommand): def __init__(self, parent, compI): super().__init__("clear preset") @@ -112,7 +119,7 @@ class ClearPreset(QUndoCommand): self.compI = compI self.component = self.parent.core.selectedComponents[compI] self.store = self.component.savePreset() - self.store['preset'] = self.component.currentPreset + self.store["preset"] = self.component.currentPreset def redo(self): self.parent.core.clearPreset(self.compI) @@ -132,20 +139,19 @@ class OpenPreset(QUndoCommand): comp = self.parent.core.selectedComponents[compI] self.store = comp.savePreset() - self.store['preset'] = copy(comp.currentPreset) + self.store["preset"] = copy(comp.currentPreset) def redo(self): self.parent._openPreset(self.presetName, self.compI) def undo(self): - self.parent.core.selectedComponents[self.compI].loadPreset( - self.store) + self.parent.core.selectedComponents[self.compI].loadPreset(self.store) self.parent.parent.updateComponentTitle(self.compI, self.store) class RenamePreset(QUndoCommand): def __init__(self, parent, path, oldName, newName): - super().__init__('rename preset') + super().__init__("rename preset") self.parent = parent self.path = path self.oldName = oldName @@ -162,14 +168,13 @@ class DeletePreset(QUndoCommand): def __init__(self, parent, compName, vers, presetFile): self.parent = parent self.preset = (compName, vers, presetFile) - self.path = os.path.join( - Core.presetDir, compName, str(vers), presetFile - ) + self.path = os.path.join(Core.presetDir, compName, str(vers), presetFile) self.store = self.parent.core.getPreset(self.path) - self.presetName = self.store['preset'] - super().__init__('delete %s preset (%s)' % (self.presetName, compName)) + self.presetName = self.store["preset"] + super().__init__("delete %s preset (%s)" % (self.presetName, compName)) self.loadedPresets = [ - i for i, comp in enumerate(self.parent.core.selectedComponents) + i + for i, comp in enumerate(self.parent.core.selectedComponents) if self.presetName == str(comp.currentPreset) ] diff --git a/src/gui/mainwindow.py b/src/gui/mainwindow.py index 159dc02..b0a564b 100644 --- a/src/gui/mainwindow.py +++ b/src/gui/mainwindow.py @@ -1,11 +1,13 @@ -''' - When using GUI mode, this module's object (the main window) takes - user input to construct a program state (stored in the Core object). - This shows a preview of the video being created and allows for saving - projects and exporting the video at a later time. -''' -from PyQt5 import QtCore, QtWidgets, uic -import PyQt5.QtWidgets as QtWidgets +""" +When using GUI mode, this module's object (the main window) takes +user input to construct a program state (stored in the Core object). +This shows a preview of the video being created and allows for saving +projects and exporting the video at a later time. +""" + +from PyQt6 import QtCore, QtWidgets, uic +import PyQt6.QtWidgets as QtWidgets +from PyQt6.QtGui import QUndoStack, QShortcut from PIL import Image from queue import Queue import sys @@ -21,46 +23,59 @@ from .preview_win import PreviewWindow from .presetmanager import PresetManager from .actions import * from ..toolkit import ( - disableWhenEncoding, disableWhenOpeningProject, checkOutput, blockSignals + disableWhenEncoding, + disableWhenOpeningProject, + checkOutput, + blockSignals, ) -appName = 'Audio Visualizer' -log = logging.getLogger('AVP.Gui.MainWindow') +appName = "Audio Visualizer" +log = logging.getLogger("AVP.Gui.MainWindow") + + +class MyQUndoStack(QUndoStack): + # FIXME move this class + @property + def encoding(self): + return self.parent().encoding + + @disableWhenEncoding + def undo(self, *args, **kwargs): + super().undo(*args, **kwargs) + + @disableWhenEncoding + def redo(self, *args, **kwargs): + super().redo(*args, **kwargs) 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. + """ + 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. - ''' + 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 processTask = QtCore.pyqtSignal() - def __init__(self, project): + def __init__(self, project, dpi): super().__init__() - log.debug( - 'Main thread id: {}'.format(int(QtCore.QThread.currentThreadId()))) + log.debug("Main thread id: {}".format(int(QtCore.QThread.currentThreadId()))) uic.loadUi(os.path.join(Core.wd, "gui", "mainwindow.ui"), self) - desk = QtWidgets.QDesktopWidget() - dpi = desk.physicalDpiX() - log.info("Detected screen DPI: %s", dpi) - - self.resize( - int(self.width() * - (dpi / 144)), - int(self.height() * - (dpi / 144)) - ) + + if dpi: + self.resize( + int(self.width() * (dpi / 144)), + int(self.height() * (dpi / 144)), + ) self.core = Core() - Core.mode = 'GUI' + Core.mode = "GUI" # widgets of component settings self.pages = [] self.lastAutosave = time.time() @@ -72,15 +87,13 @@ class MainWindow(QtWidgets.QMainWindow): # Find settings created by Core object self.dataDir = Core.dataDir self.presetDir = Core.presetDir - self.autosavePath = os.path.join(self.dataDir, 'autosave.avp') + self.autosavePath = os.path.join(self.dataDir, "autosave.avp") self.settings = Core.settings # Create stack of undoable user actions - self.undoStack = QtWidgets.QUndoStack(self) + self.undoStack = MyQUndoStack(self) undoLimit = self.settings.value("pref_undoLimit") self.undoStack.setUndoLimit(undoLimit) - self.undoStack.undo = disableWhenEncoding(self.undoStack.undo) - self.undoStack.redo = disableWhenEncoding(self.undoStack.redo) # Create Undo Dialog - A standard QUndoView on a standard QDialog self.undoDialog = QtWidgets.QDialog(self) @@ -94,18 +107,17 @@ class MainWindow(QtWidgets.QMainWindow): self.presetManager = PresetManager(self) # Create the preview window and its thread, queues, and timers - log.debug('Creating preview window') - self.previewWindow = PreviewWindow(self, os.path.join( - Core.wd, 'gui', "background.png")) + log.debug("Creating preview window") + self.previewWindow = PreviewWindow( + self, os.path.join(Core.wd, "gui", "background.png") + ) self.verticalLayout_previewWrapper.addWidget(self.previewWindow) - log.debug('Starting preview thread') + log.debug("Starting preview thread") self.previewQueue = Queue() self.previewThread = QtCore.QThread(self) self.previewWorker = preview_thread.Worker( - self.core, - self.settings, - self.previewQueue + self.core, self.settings, self.previewQueue ) self.previewWorker.moveToThread(self.previewThread) self.newTask.connect(self.previewWorker.createPreviewImage) @@ -113,12 +125,12 @@ class MainWindow(QtWidgets.QMainWindow): self.previewWorker.error.connect(self.previewWindow.threadError) self.previewWorker.imageCreated.connect(self.showPreviewImage) self.previewThread.start() - self.previewThread.finished.connect(lambda: log.info('Preview thread finished.')) + self.previewThread.finished.connect( + lambda: log.info("Preview thread finished.") + ) timeout = 500 - log.debug( - 'Preview timer set to trigger when idle for %sms' % str(timeout) - ) + log.debug("Preview timer set to trigger when idle for %sms" % str(timeout)) self.timer = QtCore.QTimer(self) self.timer.timeout.connect(self.processTask.emit) self.timer.start(timeout) @@ -128,7 +140,7 @@ class MainWindow(QtWidgets.QMainWindow): # Undo Feature def toggleUndoButtonEnabled(*_): - """ Enable/disable undo button depending on whether UndoStack contains Actions """ + """Enable/disable undo button depending on whether UndoStack contains Actions""" try: undoButton.setEnabled(self.undoStack.count()) except RuntimeError: @@ -138,50 +150,41 @@ class MainWindow(QtWidgets.QMainWindow): style = self.pushButton_undo.style() undoButton = self.pushButton_undo undoButton.setIcon( - style.standardIcon(QtWidgets.QStyle.SP_FileDialogBack) + style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_FileDialogBack) ) undoButton.clicked.connect(self.undoStack.undo) undoButton.setEnabled(False) self.undoStack.cleanChanged.connect(toggleUndoButtonEnabled) self.undoMenu = QtWidgets.QMenu() - self.undoMenu.addAction( - self.undoStack.createUndoAction(self) - ) - self.undoMenu.addAction( - self.undoStack.createRedoAction(self) - ) - action = self.undoMenu.addAction('Show History...') - action.triggered.connect( - lambda _: self.showUndoStack() - ) + self.undoMenu.addAction(self.undoStack.createUndoAction(self)) + self.undoMenu.addAction(self.undoStack.createRedoAction(self)) + action = self.undoMenu.addAction("Show History...") + action.triggered.connect(lambda _: self.showUndoStack()) undoButton.setMenu(self.undoMenu) # end of Undo Feature style = self.pushButton_listMoveUp.style() self.pushButton_listMoveUp.setIcon( - style.standardIcon(QtWidgets.QStyle.SP_ArrowUp) + style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowUp) ) style = self.pushButton_listMoveDown.style() self.pushButton_listMoveDown.setIcon( - style.standardIcon(QtWidgets.QStyle.SP_ArrowDown) + style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowDown) ) style = self.pushButton_removeComponent.style() self.pushButton_removeComponent.setIcon( - style.standardIcon(QtWidgets.QStyle.SP_DialogDiscardButton) + style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DialogDiscardButton) ) - if sys.platform == 'darwin': - log.debug( - 'Darwin detected: showing progress label below progress bar') + if sys.platform == "darwin": + log.debug("Darwin detected: showing progress label below progress bar") self.progressBar_createVideo.setTextVisible(False) else: self.progressLabel.setHidden(True) - self.toolButton_selectAudioFile.clicked.connect( - self.openInputFileDialog) + self.toolButton_selectAudioFile.clicked.connect(self.openInputFileDialog) - self.toolButton_selectOutputFile.clicked.connect( - self.openOutputFileDialog) + self.toolButton_selectOutputFile.clicked.connect(self.openOutputFileDialog) def changedField(): self.autosave() @@ -192,43 +195,36 @@ class MainWindow(QtWidgets.QMainWindow): self.progressBar_createVideo.setValue(0) - self.pushButton_createVideo.clicked.connect( - self.createAudioVisualization) + self.pushButton_createVideo.clicked.connect(self.createAudioVisualization) self.pushButton_Cancel.clicked.connect(self.stopVideo) - for i, container in enumerate(Core.encoderOptions['containers']): - self.comboBox_videoContainer.addItem(container['name']) - if container['name'] == self.settings.value('outputContainer'): + for i, container in enumerate(Core.encoderOptions["containers"]): + self.comboBox_videoContainer.addItem(container["name"]) + if container["name"] == self.settings.value("outputContainer"): selectedContainer = i self.comboBox_videoContainer.setCurrentIndex(selectedContainer) - self.comboBox_videoContainer.currentIndexChanged.connect( - self.updateCodecs - ) + self.comboBox_videoContainer.currentIndexChanged.connect(self.updateCodecs) self.updateCodecs() for i in range(self.comboBox_videoCodec.count()): codec = self.comboBox_videoCodec.itemText(i) - if codec == self.settings.value('outputVideoCodec'): + if codec == self.settings.value("outputVideoCodec"): self.comboBox_videoCodec.setCurrentIndex(i) for i in range(self.comboBox_audioCodec.count()): codec = self.comboBox_audioCodec.itemText(i) - if codec == self.settings.value('outputAudioCodec'): + if codec == self.settings.value("outputAudioCodec"): self.comboBox_audioCodec.setCurrentIndex(i) - self.comboBox_videoCodec.currentIndexChanged.connect( - self.updateCodecSettings - ) + self.comboBox_videoCodec.currentIndexChanged.connect(self.updateCodecSettings) - self.comboBox_audioCodec.currentIndexChanged.connect( - self.updateCodecSettings - ) + self.comboBox_audioCodec.currentIndexChanged.connect(self.updateCodecSettings) - vBitrate = int(self.settings.value('outputVideoBitrate')) - aBitrate = int(self.settings.value('outputAudioBitrate')) + vBitrate = int(self.settings.value("outputVideoBitrate")) + aBitrate = int(self.settings.value("outputAudioBitrate")) self.spinBox_vBitrate.setValue(vBitrate) self.spinBox_aBitrate.setValue(aBitrate) @@ -239,30 +235,27 @@ class MainWindow(QtWidgets.QMainWindow): self.compMenu = QtWidgets.QMenu() for i, comp in enumerate(self.core.modules): action = self.compMenu.addAction(comp.Component.name) - action.triggered.connect( - lambda _, item=i: self.addComponent(0, item) - ) + action.triggered.connect(lambda _, item=i: self.addComponent(0, item)) self.pushButton_addComponent.setMenu(self.compMenu) componentList.dropEvent = self.dragComponent - componentList.itemSelectionChanged.connect( - self.changeComponentWidget - ) + componentList.itemSelectionChanged.connect(self.changeComponentWidget) componentList.itemSelectionChanged.connect( self.presetManager.clearPresetListSelection ) - self.pushButton_removeComponent.clicked.connect( - lambda: self.removeComponent() - ) + self.pushButton_removeComponent.clicked.connect(lambda: self.removeComponent()) - componentList.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - componentList.customContextMenuRequested.connect( - self.componentContextMenu + componentList.setContextMenuPolicy( + QtCore.Qt.ContextMenuPolicy.CustomContextMenu ) + componentList.customContextMenuRequested.connect(self.componentContextMenu) - currentRes = str(self.settings.value('outputWidth'))+'x' + \ - str(self.settings.value('outputHeight')) + currentRes = ( + str(self.settings.value("outputWidth")) + + "x" + + str(self.settings.value("outputHeight")) + ) for i, res in enumerate(Core.resolutions): self.comboBox_resolution.addItem(res) if res == currentRes: @@ -272,24 +265,14 @@ class MainWindow(QtWidgets.QMainWindow): self.updateResolution ) - self.pushButton_listMoveUp.clicked.connect( - lambda: self.moveComponent(-1) - ) - self.pushButton_listMoveDown.clicked.connect( - lambda: self.moveComponent(1) - ) + self.pushButton_listMoveUp.clicked.connect(lambda: self.moveComponent(-1)) + self.pushButton_listMoveDown.clicked.connect(lambda: self.moveComponent(1)) # Configure the Projects Menu self.projectMenu = QtWidgets.QMenu() - self.menuButton_newProject = self.projectMenu.addAction( - "New Project" - ) - self.menuButton_newProject.triggered.connect( - lambda: self.createNewProject() - ) - self.menuButton_openProject = self.projectMenu.addAction( - "Open Project" - ) + self.menuButton_newProject = self.projectMenu.addAction("New Project") + self.menuButton_newProject.triggered.connect(lambda: self.createNewProject()) + self.menuButton_openProject = self.projectMenu.addAction("Open Project") self.menuButton_openProject.triggered.connect( lambda: self.openOpenProjectDialog() ) @@ -303,22 +286,18 @@ class MainWindow(QtWidgets.QMainWindow): self.pushButton_projects.setMenu(self.projectMenu) # Configure the Presets Button - self.pushButton_presets.clicked.connect( - self.openPresetManager - ) + self.pushButton_presets.clicked.connect(self.openPresetManager) self.updateWindowTitle() - log.debug('Showing main window') + log.debug("Showing main window") self.show() if project and project != self.autosavePath: - if not project.endswith('.avp'): - project += '.avp' + if not project.endswith(".avp"): + project += ".avp" # open a project from the commandline if not os.path.dirname(project): - project = os.path.join( - self.settings.value("projectDir"), project - ) + project = os.path.join(self.settings.value("projectDir"), project) self.currentProject = project self.settings.setValue("currentProject", project) if os.path.exists(self.autosavePath): @@ -335,7 +314,8 @@ class MainWindow(QtWidgets.QMainWindow): ch = self.showMessage( msg="Restore unsaved changes in project '%s'?" % os.path.basename(self.currentProject)[:-4], - showCancel=True) + showCancel=True, + ) if ch: self.saveProjectChanges() else: @@ -352,16 +332,16 @@ class MainWindow(QtWidgets.QMainWindow): msg="FFmpeg could not be found. This is a critical error. " "Install FFmpeg, or download it and place the program executable " "in the same folder as this program.", - icon='Critical' + icon="Critical", ) else: if not self.settings.value("ffmpegMsgShown"): try: with open(os.devnull, "w") as f: ffmpegVers = checkOutput( - [self.core.FFMPEG_BIN, '-version'], stderr=f + [self.core.FFMPEG_BIN, "-version"], stderr=f ) - goodVersion = str(ffmpegVers).split()[2].startswith('4') + goodVersion = str(ffmpegVers).split()[2].startswith("4") except Exception: goodVersion = False else: @@ -375,70 +355,61 @@ class MainWindow(QtWidgets.QMainWindow): self.settings.setValue("ffmpegMsgShown", True) # Hotkeys for projects - QtWidgets.QShortcut("Ctrl+S", self, self.saveCurrentProject) - QtWidgets.QShortcut("Ctrl+A", self, self.openSaveProjectDialog) - QtWidgets.QShortcut("Ctrl+O", self, self.openOpenProjectDialog) - QtWidgets.QShortcut("Ctrl+N", self, self.createNewProject) + + QShortcut("Ctrl+S", self, self.saveCurrentProject) + QShortcut("Ctrl+A", self, self.openSaveProjectDialog) + QShortcut("Ctrl+O", self, self.openOpenProjectDialog) + QShortcut("Ctrl+N", self, self.createNewProject) # Hotkeys for undo/redo - QtWidgets.QShortcut("Ctrl+Z", self, self.undoStack.undo) - QtWidgets.QShortcut("Ctrl+Y", self, self.undoStack.redo) - QtWidgets.QShortcut("Ctrl+Shift+Z", self, self.undoStack.redo) + QShortcut("Ctrl+Z", self, self.undoStack.undo) + QShortcut("Ctrl+Y", self, self.undoStack.redo) + QShortcut("Ctrl+Shift+Z", self, self.undoStack.redo) # Hotkeys for component list - for inskey in ("Ctrl+T", QtCore.Qt.Key_Insert): - QtWidgets.QShortcut( - inskey, self, - activated=lambda: self.pushButton_addComponent.click() - ) - for delkey in ("Ctrl+R", QtCore.Qt.Key_Delete): - QtWidgets.QShortcut( - delkey, self.listWidget_componentList, - self.removeComponent + for inskey in ("Ctrl+T", QtCore.Qt.Key.Key_Insert): + QShortcut( + inskey, + self, + activated=lambda: self.pushButton_addComponent.click(), ) - QtWidgets.QShortcut( - "Ctrl+Space", self, - activated=lambda: self.listWidget_componentList.setFocus() - ) - QtWidgets.QShortcut( - "Ctrl+Shift+S", self, - self.presetManager.openSavePresetDialog - ) - QtWidgets.QShortcut( - "Ctrl+Shift+C", self, self.presetManager.clearPreset + for delkey in ("Ctrl+R", QtCore.Qt.Key.Key_Delete): + QShortcut(delkey, self.listWidget_componentList, self.removeComponent) + QShortcut( + "Ctrl+Space", + self, + activated=lambda: self.listWidget_componentList.setFocus(), ) + QShortcut("Ctrl+Shift+S", self, self.presetManager.openSavePresetDialog) + QShortcut("Ctrl+Shift+C", self, self.presetManager.clearPreset) - QtWidgets.QShortcut( - "Ctrl+Up", self.listWidget_componentList, - activated=lambda: self.moveComponent(-1) + QShortcut( + "Ctrl+Up", + self.listWidget_componentList, + activated=lambda: self.moveComponent(-1), ) - QtWidgets.QShortcut( - "Ctrl+Down", self.listWidget_componentList, - activated=lambda: self.moveComponent(1) + QShortcut( + "Ctrl+Down", + self.listWidget_componentList, + activated=lambda: self.moveComponent(1), ) - QtWidgets.QShortcut( - "Ctrl+Home", self.listWidget_componentList, - activated=lambda: self.moveComponent('top') + QShortcut( + "Ctrl+Home", + self.listWidget_componentList, + activated=lambda: self.moveComponent("top"), ) - QtWidgets.QShortcut( - "Ctrl+End", self.listWidget_componentList, - activated=lambda: self.moveComponent('bottom') + QShortcut( + "Ctrl+End", + self.listWidget_componentList, + activated=lambda: self.moveComponent("bottom"), ) - QtWidgets.QShortcut( - "Ctrl+Shift+F", self, self.showFfmpegCommand - ) - QtWidgets.QShortcut( - "Ctrl+Shift+U", self, self.showUndoStack - ) + QShortcut("Ctrl+Shift+F", self, self.showFfmpegCommand) + QShortcut("Ctrl+Shift+U", self, self.showUndoStack) if log.isEnabledFor(logging.DEBUG): - QtWidgets.QShortcut( - "Ctrl+Alt+Shift+R", self, self.drawPreview - ) - QtWidgets.QShortcut( - "Ctrl+Alt+Shift+A", self, lambda: log.debug(repr(self)) - ) + QShortcut("Ctrl+Alt+Shift+R", self, self.drawPreview) + QShortcut("Ctrl+Alt+Shift+A", self, lambda: log.debug(repr(self))) # Close MainWindow when receiving Ctrl+C from terminal signal.signal(signal.SIGINT, lambda *args: self.close()) @@ -450,18 +421,27 @@ class MainWindow(QtWidgets.QMainWindow): def __repr__(self): return ( - '%s\n' - '\n%s\n' - '#####\n' - 'Preview thread is %s\n' % ( + "%s\n" + "\n%s\n" + "#####\n" + "Preview thread is %s\n" + % ( super().__repr__(), - "core not initialized" if not hasattr(self, "core") else repr(self.core), - 'live' if hasattr(self, "previewThread") and self.previewThread.isRunning() else 'dead', + ( + "core not initialized" + if not hasattr(self, "core") + else repr(self.core) + ), + ( + "live" + if hasattr(self, "previewThread") and self.previewThread.isRunning() + else "dead" + ), ) ) def closeEvent(self, event): - log.info('Ending the preview thread') + log.info("Ending the preview thread") self.timer.stop() self.previewThread.quit() self.previewThread.wait() @@ -473,11 +453,11 @@ class MainWindow(QtWidgets.QMainWindow): windowTitle = appName try: if self.currentProject: - windowTitle += ' - %s' % \ - os.path.splitext( - os.path.basename(self.currentProject))[0] + windowTitle += ( + " - %s" % os.path.splitext(os.path.basename(self.currentProject))[0] + ) if self.autosaveExists(identical=False): - windowTitle += '*' + windowTitle += "*" except AttributeError: pass log.verbose(f'Window title is "{windowTitle}"') @@ -485,38 +465,38 @@ class MainWindow(QtWidgets.QMainWindow): @QtCore.pyqtSlot(int, dict) def updateComponentTitle(self, pos, presetStore=False): - ''' - Sets component title to modified or unmodified when given boolean. - If given a preset dict, compares it against the component to - determine if it is modified. - A component with no preset is always unmodified. - ''' + """ + Sets component title to modified or unmodified when given boolean. + If given a preset dict, compares it against the component to + determine if it is modified. + A component with no preset is always unmodified. + """ if type(presetStore) is dict: - name = presetStore['preset'] + name = presetStore["preset"] if name is None or name not in self.core.savedPresets: modified = False else: - modified = (presetStore != self.core.savedPresets[name]) + modified = presetStore != self.core.savedPresets[name] modified = bool(presetStore) if pos < 0: - pos = len(self.core.selectedComponents)-1 + pos = len(self.core.selectedComponents) - 1 name = self.core.selectedComponents[pos].name title = str(name) if self.core.selectedComponents[pos].currentPreset: - title += ' - %s' % self.core.selectedComponents[pos].currentPreset + title += " - %s" % self.core.selectedComponents[pos].currentPreset if modified: - title += '*' + title += "*" if type(presetStore) is bool: log.debug( - 'Forcing %s #%s\'s modified status to %s: %s', - name, pos, modified, title + "Forcing %s #%s's modified status to %s: %s", + name, + pos, + modified, + title, ) else: - log.debug( - 'Setting %s #%s\'s title: %s', - name, pos, title - ) + log.debug("Setting %s #%s's title: %s", name, pos, title) self.listWidget_componentList.item(pos).setText(title) def updateCodecs(self): @@ -525,20 +505,20 @@ class MainWindow(QtWidgets.QMainWindow): aCodecWidget = self.comboBox_audioCodec index = containerWidget.currentIndex() name = containerWidget.itemText(index) - self.settings.setValue('outputContainer', name) + self.settings.setValue("outputContainer", name) vCodecWidget.clear() aCodecWidget.clear() - for container in Core.encoderOptions['containers']: - if container['name'] == name: - for vCodec in container['video-codecs']: + for container in Core.encoderOptions["containers"]: + if container["name"] == name: + for vCodec in container["video-codecs"]: vCodecWidget.addItem(vCodec) - for aCodec in container['audio-codecs']: + for aCodec in container["audio-codecs"]: aCodecWidget.addItem(aCodec) def updateCodecSettings(self): - '''Updates settings.ini to match encoder option widgets''' + """Updates settings.ini to match encoder option widgets""" vCodecWidget = self.comboBox_videoCodec vBitrateWidget = self.spinBox_vBitrate aBitrateWidget = self.spinBox_aBitrate @@ -549,10 +529,10 @@ class MainWindow(QtWidgets.QMainWindow): currentAudioCodec = aCodecWidget.currentIndex() currentAudioCodec = aCodecWidget.itemText(currentAudioCodec) currentAudioBitrate = aBitrateWidget.value() - self.settings.setValue('outputVideoCodec', currentVideoCodec) - self.settings.setValue('outputAudioCodec', currentAudioCodec) - self.settings.setValue('outputVideoBitrate', currentVideoBitrate) - self.settings.setValue('outputAudioBitrate', currentAudioBitrate) + self.settings.setValue("outputVideoCodec", currentVideoCodec) + self.settings.setValue("outputAudioCodec", currentAudioCodec) + self.settings.setValue("outputVideoBitrate", currentVideoBitrate) + self.settings.setValue("outputAudioBitrate", currentAudioBitrate) @disableWhenOpeningProject def autosave(self, force=False): @@ -567,54 +547,54 @@ class MainWindow(QtWidgets.QMainWindow): # 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 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 + 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) else: - log.debug('Autosave rejected by cooldown') + log.debug("Autosave rejected by cooldown") def autosaveExists(self, identical=True): - '''Determines if creating the autosave should be blocked.''' + """Determines if creating the autosave should be blocked.""" try: - if self.currentProject and os.path.exists(self.autosavePath) \ - and filecmp.cmp( - self.autosavePath, self.currentProject) == identical: + if ( + self.currentProject + and os.path.exists(self.autosavePath) + and filecmp.cmp(self.autosavePath, self.currentProject) == identical + ): log.debug( - 'Autosave found %s to be identical' - % 'not' if not identical else '' + "Autosave found %s to be identical" % "not" if not identical else "" ) return True except FileNotFoundError: - log.error( - 'Project file couldn\'t be located: %s', self.currentProject) + log.error("Project file couldn't be located: %s", self.currentProject) return identical return False def saveProjectChanges(self): - '''Overwrites project file with autosave file''' + """Overwrites project file with autosave file""" try: os.remove(self.currentProject) os.rename(self.autosavePath, self.currentProject) return True except (FileNotFoundError, IsADirectoryError) as e: - self.showMessage( - msg='Project file couldn\'t be saved.', - detail=str(e)) + self.showMessage(msg="Project file couldn't be saved.", detail=str(e)) return False def openInputFileDialog(self): inputDir = self.settings.value("inputDir", os.path.expanduser("~")) fileName, _ = QtWidgets.QFileDialog.getOpenFileName( - self, "Open Audio File", - inputDir, "Audio Files (%s)" % " ".join(Core.audioFormats)) + self, + "Open Audio File", + inputDir, + "Audio Files (%s)" % " ".join(Core.audioFormats), + ) if fileName: self.settings.setValue("inputDir", os.path.dirname(fileName)) @@ -624,17 +604,18 @@ class MainWindow(QtWidgets.QMainWindow): outputDir = self.settings.value("outputDir", os.path.expanduser("~")) fileName, _ = QtWidgets.QFileDialog.getSaveFileName( - self, "Set Output Video File", + self, + "Set Output Video File", outputDir, - "Video Files (%s);; All Files (*)" % " ".join( - Core.videoFormats)) + "Video Files (%s);; All Files (*)" % " ".join(Core.videoFormats), + ) if fileName: self.settings.setValue("outputDir", os.path.dirname(fileName)) self.lineEdit_outputFile.setText(fileName) def stopVideo(self): - log.info('Export cancelled') + log.info("Export cancelled") self.videoWorker.cancel() self.canceled = True @@ -645,14 +626,13 @@ class MainWindow(QtWidgets.QMainWindow): if audioFile and outputPath and self.core.selectedComponents: if not os.path.dirname(outputPath): - outputPath = os.path.join( - os.path.expanduser("~"), outputPath) + outputPath = os.path.join(os.path.expanduser("~"), outputPath) if outputPath and os.path.isdir(outputPath): self.showMessage( - msg='Chosen filename matches a directory, which ' - 'cannot be overwritten. Please choose a different ' - 'filename or move the directory.', - icon='Warning', + msg="Chosen filename matches a directory, which " + "cannot be overwritten. Please choose a different " + "filename or move the directory.", + icon="Warning", ) return else: @@ -661,19 +641,14 @@ class MainWindow(QtWidgets.QMainWindow): msg="You must select an audio file and output filename." ) elif not self.core.selectedComponents: - self.showMessage( - msg="Not enough components." - ) + self.showMessage(msg="Not enough components.") return self.canceled = False self.progressBarUpdated(-1) - self.videoWorker = self.core.newVideoWorker( - self, audioFile, outputPath - ) + self.videoWorker = self.core.newVideoWorker(self, audioFile, outputPath) self.videoWorker.progressBarUpdate.connect(self.progressBarUpdated) - self.videoWorker.progressBarSetText.connect( - self.progressBarSetText) + self.videoWorker.progressBarSetText.connect(self.progressBarSetText) self.videoWorker.imageCreated.connect(self.showPreviewImage) self.videoWorker.encoding.connect(self.changeEncodingStatus) self.createVideo.emit() @@ -683,14 +658,14 @@ class MainWindow(QtWidgets.QMainWindow): try: self.stopVideo() except AttributeError as e: - if 'videoWorker' not in str(e): + if "videoWorker" not in str(e): raise self.showMessage( msg=msg, detail=detail, - icon='Critical', + icon="Critical", ) - log.info('%s', repr(self)) + log.info("%s", repr(self)) def changeEncodingStatus(self, status): self.encoding = status @@ -718,7 +693,7 @@ class MainWindow(QtWidgets.QMainWindow): # Close undo history dialog if open self.undoDialog.close() # Show label under progress bar on macOS - if sys.platform == 'darwin': + if sys.platform == "darwin": self.progressLabel.setHidden(False) else: self.pushButton_createVideo.setEnabled(True) @@ -749,33 +724,33 @@ class MainWindow(QtWidgets.QMainWindow): @QtCore.pyqtSlot(str) def progressBarSetText(self, value): - if sys.platform == 'darwin': + if sys.platform == "darwin": self.progressLabel.setText(value) else: self.progressBar_createVideo.setFormat(value) def updateResolution(self): resIndex = int(self.comboBox_resolution.currentIndex()) - res = Core.resolutions[resIndex].split('x') + res = Core.resolutions[resIndex].split("x") changed = res[0] != self.settings.value("outputWidth") - self.settings.setValue('outputWidth', res[0]) - self.settings.setValue('outputHeight', res[1]) + self.settings.setValue("outputWidth", res[0]) + self.settings.setValue("outputHeight", res[1]) if changed: for i in range(len(self.core.selectedComponents)): self.core.updateComponent(i) def drawPreview(self, force=False, **kwargs): - '''Use autosave keyword arg to force saving or not saving if needed''' + """Use autosave keyword arg to force saving or not saving if needed""" self.newTask.emit(self.core.selectedComponents) # self.processTask.emit() - if force or 'autosave' in kwargs: - if force or kwargs['autosave']: + if force or "autosave" in kwargs: + if force or kwargs["autosave"]: self.autosave(True) else: self.autosave() self.updateWindowTitle() - @QtCore.pyqtSlot('QImage') + @QtCore.pyqtSlot("QImage") def showPreviewImage(self, image): self.previewWindow.changePixmap(image) @@ -786,36 +761,35 @@ class MainWindow(QtWidgets.QMainWindow): def showFfmpegCommand(self): from textwrap import wrap from ..toolkit.ffmpeg import createFfmpegCommand + command = createFfmpegCommand( self.lineEdit_audioFile.text(), self.lineEdit_outputFile.text(), - self.core.selectedComponents + self.core.selectedComponents, ) command = " ".join(command) log.info(f"FFmpeg command: {command}") lines = wrap(command, 49) - self.showMessage( - msg=f"Current FFmpeg command:\n\n{' '.join(lines)}" - ) + self.showMessage(msg=f"Current FFmpeg command:\n\n{' '.join(lines)}") def addComponent(self, compPos, moduleIndex): - '''Creates an undoable action that adds a new component.''' + """Creates an undoable action that adds a new component.""" action = AddComponent(self, compPos, moduleIndex) self.undoStack.push(action) def insertComponent(self, index): - '''Triggered by Core to finish initializing a new component.''' + """Triggered by Core to finish initializing a new component.""" + if not hasattr(self.core.selectedComponents[index], "page"): + log.error("Component failed to initialize") + return componentList = self.listWidget_componentList stackedWidget = self.stackedWidget - componentList.insertItem( - index, - self.core.selectedComponents[index].name) + componentList.insertItem(index, self.core.selectedComponents[index].name) componentList.setCurrentRow(index) # connect to signal that adds an asterisk when modified - self.core.selectedComponents[index].modified.connect( - self.updateComponentTitle) + self.core.selectedComponents[index].modified.connect(self.updateComponentTitle) self.pages.insert(index, self.core.selectedComponents[index].page) stackedWidget.insertWidget(index, self.pages[index]) @@ -842,15 +816,15 @@ class MainWindow(QtWidgets.QMainWindow): @disableWhenEncoding def moveComponent(self, change): - '''Moves a component relatively from its current position''' + """Moves a component relatively from its current position""" componentList = self.listWidget_componentList tag = change - if change == 'top': + if change == "top": change = -componentList.currentRow() - elif change == 'bottom': - change = len(componentList)-componentList.currentRow()-1 + elif change == "bottom": + change = len(componentList) - componentList.currentRow() - 1 else: - tag = 'down' if change == 1 else 'up' + tag = "down" if change == 1 else "up" row = componentList.currentRow() newRow = row + change @@ -859,38 +833,39 @@ class MainWindow(QtWidgets.QMainWindow): self.undoStack.push(action) def getComponentListMousePos(self, position): - ''' + """ Given a QPos, returns the component index under the mouse cursor or -1 if no component is there. - ''' + """ componentList = self.listWidget_componentList + if hasattr(position, "toPointF"): + position = position.toPointF() + position = position.toPoint() + modelIndexes = [ - componentList.model().index(i) - for i in range(componentList.count()) - ] - rects = [ - componentList.visualRect(modelIndex) - for modelIndex in modelIndexes + componentList.model().index(i) for i in range(componentList.count()) ] + rects = [componentList.visualRect(modelIndex) for modelIndex in modelIndexes] mousePos = [rect.contains(position) for rect in rects] if not any(mousePos): # Not clicking a component mousePos = -1 else: mousePos = mousePos.index(True) - log.debug('Click component list row %s' % mousePos) + log.debug("Click component list row %s" % mousePos) return mousePos @disableWhenEncoding def dragComponent(self, event): - '''Used as Qt drop event for the component listwidget''' + """Used as Qt drop event for the component listwidget""" componentList = self.listWidget_componentList - mousePos = self.getComponentListMousePos(event.pos()) + mousePos = self.getComponentListMousePos(event.position()) + 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): @@ -900,30 +875,27 @@ class MainWindow(QtWidgets.QMainWindow): self.stackedWidget.setCurrentIndex(index) def openPresetManager(self): - '''Preset manager for importing, exporting, renaming, deleting''' + """Preset manager for importing, exporting, renaming, deleting""" self.presetManager.show_() def clear(self): - '''Get a blank slate''' + """Get a blank slate""" self.core.clearComponents() self.listWidget_componentList.clear() for widget in self.pages: self.stackedWidget.removeWidget(widget) self.pages = [] - for field in ( - self.lineEdit_audioFile, - self.lineEdit_outputFile - ): + for field in (self.lineEdit_audioFile, self.lineEdit_outputFile): with blockSignals(field): - field.setText('') + field.setText("") self.progressBarUpdated(0) - self.progressBarSetText('') + self.progressBarSetText("") self.undoStack.clear() @disableWhenEncoding def createNewProject(self, prompt=True): if prompt: - self.openSaveChangesDialog('starting a new project') + self.openSaveChangesDialog("starting a new project") self.clear() self.currentProject = None @@ -946,11 +918,10 @@ class MainWindow(QtWidgets.QMainWindow): if self.autosaveExists(identical=False): ch = self.showMessage( msg="You have unsaved changes in project '%s'. " - "Save before %s?" % ( - os.path.basename(self.currentProject)[:-4], - phrase - ), - showCancel=True) + "Save before %s?" + % (os.path.basename(self.currentProject)[:-4], phrase), + showCancel=True, + ) if ch: success = self.saveProjectChanges() @@ -959,13 +930,15 @@ class MainWindow(QtWidgets.QMainWindow): def openSaveProjectDialog(self): filename, _ = QtWidgets.QFileDialog.getSaveFileName( - self, "Create Project File", + self, + "Create Project File", self.settings.value("projectDir"), - "Project Files (*.avp)") + "Project Files (*.avp)", + ) if not filename: return if not filename.endswith(".avp"): - filename += '.avp' + filename += ".avp" self.settings.setValue("projectDir", os.path.dirname(filename)) self.settings.setValue("currentProject", filename) self.currentProject = filename @@ -975,20 +948,25 @@ class MainWindow(QtWidgets.QMainWindow): @disableWhenEncoding def openOpenProjectDialog(self): filename, _ = QtWidgets.QFileDialog.getOpenFileName( - self, "Open Project File", + self, + "Open Project File", self.settings.value("projectDir"), - "Project Files (*.avp)") + "Project Files (*.avp)", + ) self.openProject(filename) def openProject(self, filepath, prompt=True): - if not filepath or not os.path.exists(filepath) \ - or not filepath.endswith('.avp'): + if ( + not filepath + or not os.path.exists(filepath) + or not filepath.endswith(".avp") + ): return self.clear() # ask to save any changes that are about to get deleted if prompt: - self.openSaveChangesDialog('opening another project') + self.openSaveChangesDialog("opening another project") self.currentProject = filepath self.settings.setValue("currentProject", filepath) @@ -999,29 +977,32 @@ class MainWindow(QtWidgets.QMainWindow): self.updateWindowTitle() def showMessage(self, **kwargs): - parent = kwargs['parent'] if 'parent' in kwargs else self + parent = kwargs["parent"] if "parent" in kwargs else self msg = QtWidgets.QMessageBox(parent) msg.setWindowTitle(appName) msg.setModal(True) - msg.setText(kwargs['msg']) + msg.setText(kwargs["msg"]) msg.setIcon( - eval('QtWidgets.QMessageBox.%s' % kwargs['icon']) - if 'icon' in kwargs else QtWidgets.QMessageBox.Information + eval("QtWidgets.QMessageBox.Icon.%s" % kwargs["icon"]) + if "icon" in kwargs + else QtWidgets.QMessageBox.Icon.Information ) - msg.setDetailedText(kwargs['detail'] if 'detail' in kwargs else None) - if 'showCancel'in kwargs and kwargs['showCancel']: + msg.setDetailedText(kwargs["detail"] if "detail" in kwargs else None) + if "showCancel" in kwargs and kwargs["showCancel"]: msg.setStandardButtons( - QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel) + QtWidgets.QMessageBox.StandardButton.Ok + | QtWidgets.QMessageBox.StandardButton.Cancel + ) else: - msg.setStandardButtons(QtWidgets.QMessageBox.Ok) - ch = msg.exec_() + msg.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Ok) + ch = msg.exec() if ch == 1024: return True return False @disableWhenEncoding def componentContextMenu(self, QPos): - '''Appears when right-clicking the component list''' + """Appears when right-clicking the component list""" componentList = self.listWidget_componentList self.menu = QtWidgets.QMenu() parentPosition = componentList.mapToGlobal(QtCore.QPoint(0, 0)) @@ -1031,9 +1012,7 @@ class MainWindow(QtWidgets.QMainWindow): # Show preset menu if clicking a component self.presetManager.findPresets() menuItem = self.menu.addAction("Save Preset") - menuItem.triggered.connect( - self.presetManager.openSavePresetDialog - ) + menuItem.triggered.connect(self.presetManager.openSavePresetDialog) # submenu for opening presets try: @@ -1046,17 +1025,16 @@ class MainWindow(QtWidgets.QMainWindow): for version, presetName in presets: menuItem = self.presetSubmenu.addAction(presetName) menuItem.triggered.connect( - lambda _, presetName=presetName: - self.presetManager.openPreset(presetName) + 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( - self.presetManager.clearPreset - ) + menuItem.triggered.connect(self.presetManager.clearPreset) self.menu.addSeparator() # "Add Component" submenu diff --git a/src/gui/presetmanager.py b/src/gui/presetmanager.py index 9cf95b4..11a9d9b 100644 --- a/src/gui/presetmanager.py +++ b/src/gui/presetmanager.py @@ -1,8 +1,9 @@ -''' - Preset manager object handles all interactions with presets, including - the context menu accessed from MainWindow. -''' -from PyQt5 import QtCore, QtWidgets, uic +""" +Preset manager object handles all interactions with presets, including +the context menu accessed from MainWindow. +""" + +from PyQt6 import QtCore, QtWidgets, uic import string import os import logging @@ -12,53 +13,43 @@ from ..core import Core from .actions import * -log = logging.getLogger('AVP.Gui.PresetManager') +log = logging.getLogger("AVP.Gui.PresetManager") class PresetManager(QtWidgets.QDialog): def __init__(self, parent): super().__init__() - uic.loadUi( - os.path.join(Core.wd, 'gui', 'presetmanager.ui'), self) + uic.loadUi(os.path.join(Core.wd, "gui", "presetmanager.ui"), self) self.parent = parent self.core = parent.core self.settings = parent.settings self.presetDir = parent.presetDir - if not self.settings.value('presetDir'): + if not self.settings.value("presetDir"): self.settings.setValue( - "presetDir", - os.path.join(parent.dataDir, 'projects')) + "presetDir", os.path.join(parent.dataDir, "projects") + ) self.findPresets() # window - self.lastFilter = '*' + self.lastFilter = "*" self.presetRows = [] # list of (comp, vers, name) tuples - self.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint) + + # FIXME + # self.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint) # connect button signals - self.pushButton_delete.clicked.connect( - self.openDeletePresetDialog - ) - self.pushButton_rename.clicked.connect( - self.openRenamePresetDialog - ) - self.pushButton_import.clicked.connect( - self.openImportDialog - ) - self.pushButton_export.clicked.connect( - self.openExportDialog - ) - self.pushButton_close.clicked.connect( - self.close - ) + self.pushButton_delete.clicked.connect(self.openDeletePresetDialog) + self.pushButton_rename.clicked.connect(self.openRenamePresetDialog) + self.pushButton_import.clicked.connect(self.openImportDialog) + self.pushButton_export.clicked.connect(self.openExportDialog) + self.pushButton_close.clicked.connect(self.close) # create filter box and preset list self.drawFilterList() self.comboBox_filter.currentIndexChanged.connect( lambda: self.drawPresetList( - self.comboBox_filter.currentText(), - self.lineEdit_search.text() + self.comboBox_filter.currentText(), self.lineEdit_search.text() ) ) @@ -69,17 +60,16 @@ class PresetManager(QtWidgets.QDialog): self.lineEdit_search.setCompleter(completer) self.lineEdit_search.textChanged.connect( lambda: self.drawPresetList( - self.comboBox_filter.currentText(), - self.lineEdit_search.text() + self.comboBox_filter.currentText(), self.lineEdit_search.text() ) ) - self.drawPresetList('*') + self.drawPresetList("*") def show_(self): - '''Open a new preset manager window from the mainwindow''' + """Open a new preset manager window from the mainwindow""" self.findPresets() self.drawFilterList() - self.drawPresetList('*') + self.drawPresetList("*") self.show() def findPresets(self): @@ -100,14 +90,12 @@ class PresetManager(QtWidgets.QDialog): continue self.presets = { compName: [ - (vers, preset) - for name, vers, preset in parseList - if name == compName + (vers, preset) for name, vers, preset in parseList if name == compName ] for compName, _, __ in parseList } - def drawPresetList(self, compFilter=None, presetFilter=''): + def drawPresetList(self, compFilter=None, presetFilter=""): self.listWidget_presets.clear() if compFilter: self.lastFilter = str(compFilter) @@ -116,13 +104,11 @@ class PresetManager(QtWidgets.QDialog): self.presetRows = [] presetNames = [] for component, presets in self.presets.items(): - if compFilter != '*' and component != compFilter: + if compFilter != "*" and component != compFilter: continue for vers, preset in presets: if not presetFilter or presetFilter in preset: - self.listWidget_presets.addItem( - '%s: %s' % (component, preset) - ) + self.listWidget_presets.addItem("%s: %s" % (component, preset)) self.presetRows.append((component, vers, preset)) if preset not in presetNames: presetNames.append(preset) @@ -130,18 +116,18 @@ class PresetManager(QtWidgets.QDialog): def drawFilterList(self): self.comboBox_filter.clear() - self.comboBox_filter.addItem('*') + self.comboBox_filter.addItem("*") for component in self.presets: self.comboBox_filter.addItem(component) def clearPreset(self, compI=None): - '''Functions on mainwindow level from the context menu''' + """Functions on mainwindow level from the context menu""" compI = self.parent.listWidget_componentList.currentRow() action = ClearPreset(self.parent, compI) self.parent.undoStack.push(action) def openSavePresetDialog(self): - '''Functions on mainwindow level from the context menu''' + """Functions on mainwindow level from the context menu""" selectedComponents = self.core.selectedComponents componentList = self.parent.listWidget_componentList @@ -152,10 +138,10 @@ class PresetManager(QtWidgets.QDialog): currentPreset = selectedComponents[index].currentPreset newName, OK = QtWidgets.QInputDialog.getText( self.parent, - 'Audio Visualizer', - 'New Preset Name:', - QtWidgets.QLineEdit.Normal, - currentPreset + "Audio Visualizer", + "New Preset Name:", + QtWidgets.QLineEdit.EchoMode.Normal, + currentPreset, ) if OK: if badName(newName): @@ -164,21 +150,23 @@ class PresetManager(QtWidgets.QDialog): if newName: if index != -1: selectedComponents[index].currentPreset = newName - saveValueStore = \ - selectedComponents[index].savePreset() - saveValueStore['preset'] = newName + saveValueStore = selectedComponents[index].savePreset() + saveValueStore["preset"] = newName componentName = str(selectedComponents[index]).strip() vers = selectedComponents[index].version self.createNewPreset( - componentName, vers, newName, - saveValueStore, window=self.parent) + componentName, + vers, + newName, + saveValueStore, + window=self.parent, + ) self.findPresets() self.drawPresetList() self.openPreset(newName, index) break - def createNewPreset( - self, compName, vers, filename, saveValueStore, **kwargs): + def createNewPreset(self, compName, vers, filename, saveValueStore, **kwargs): path = os.path.join(self.presetDir, compName, str(vers), filename) if self.presetExists(path, **kwargs): return @@ -188,11 +176,11 @@ class PresetManager(QtWidgets.QDialog): if os.path.exists(path): window = kwargs.get("window", self) ch = self.parent.showMessage( - msg="%s already exists! Overwrite it?" % - os.path.basename(path), + msg="%s already exists! Overwrite it?" % os.path.basename(path), showCancel=True, - icon='Warning', - parent=window) + icon="Warning", + parent=window, + ) if not ch: # user clicked cancel return True @@ -225,10 +213,10 @@ class PresetManager(QtWidgets.QDialog): return comp, vers, name = self.presetRows[row] ch = self.parent.showMessage( - msg='Really delete %s?' % name, + msg="Really delete %s?" % name, showCancel=True, - icon='Warning', - parent=self + icon="Warning", + parent=self, ) if not ch: return @@ -240,9 +228,9 @@ class PresetManager(QtWidgets.QDialog): def warnMessage(self, window=None): self.parent.showMessage( - msg='Preset names must contain only letters, ' - 'numbers, and spaces.', - parent=window if window else self) + msg="Preset names must contain only letters, " "numbers, and spaces.", + parent=window if window else self, + ) def getPresetRow(self): row = self.listWidget_presets.currentRow() @@ -262,14 +250,14 @@ class PresetManager(QtWidgets.QDialog): rowTuple = ( self.core.selectedComponents[compIndex].name, self.core.selectedComponents[compIndex].version, - preset + preset, ) for i, tup in enumerate(self.presetRows): if rowTuple == tup: index = i break else: - return -1 + return -1 return index def openRenamePresetDialog(self): @@ -281,10 +269,10 @@ class PresetManager(QtWidgets.QDialog): while True: newName, OK = QtWidgets.QInputDialog.getText( self, - 'Preset Manager', - 'Rename Preset:', - QtWidgets.QLineEdit.Normal, - self.presetRows[index][2] + "Preset Manager", + "Rename Preset:", + QtWidgets.QLineEdit.EchoMode.Normal, + self.presetRows[index][2], ) if OK: if badName(newName): @@ -292,8 +280,7 @@ class PresetManager(QtWidgets.QDialog): continue if newName: comp, vers, oldName = self.presetRows[index] - path = os.path.join( - self.presetDir, comp, str(vers)) + path = os.path.join(self.presetDir, comp, str(vers)) newPath = os.path.join(path, newName) if self.presetExists(newPath): return @@ -311,20 +298,21 @@ class PresetManager(QtWidgets.QDialog): self.drawPresetList() path = os.path.dirname(newPath) for i, comp in enumerate(self.core.selectedComponents): - if self.core.getPresetDir(comp) == path \ - and comp.currentPreset == oldName: + if self.core.getPresetDir(comp) == path and comp.currentPreset == oldName: self.core.openPreset(newPath, i, newName) self.parent.updateComponentTitle(i, False) self.parent.drawPreview() def openImportDialog(self): filename, _ = QtWidgets.QFileDialog.getOpenFileName( - self, "Import Preset File", + self, + "Import Preset File", self.settings.value("presetDir"), - "Preset Files (*.avl)") + "Preset Files (*.avl)", + ) if filename: # get installed path & ask user to overwrite if needed - path = '' + path = "" while True: if path: if self.presetExists(path): @@ -345,15 +333,16 @@ class PresetManager(QtWidgets.QDialog): if index == -1: return filename, _ = QtWidgets.QFileDialog.getSaveFileName( - self, "Export Preset", + self, + "Export Preset", self.settings.value("presetDir"), - "Preset Files (*.avl)") + "Preset Files (*.avl)", + ) if filename: comp, vers, name = self.presetRows[index] if not self.core.exportPreset(filename, comp, vers, name): self.parent.showMessage( - msg='Couldn\'t export %s.' % filename, - parent=self + msg="Couldn't export %s." % filename, parent=self ) self.settings.setValue("presetDir", os.path.dirname(filename)) diff --git a/src/gui/preview_thread.py b/src/gui/preview_thread.py index 3943a5c..1d78516 100644 --- a/src/gui/preview_thread.py +++ b/src/gui/preview_thread.py @@ -1,9 +1,10 @@ -''' - Thread that runs to create QImages for MainWindow's preview label. - Processes a queue of component lists. -''' -from PyQt5 import QtCore, QtGui, uic -from PyQt5.QtCore import pyqtSignal, pyqtSlot +""" +Thread that runs to create QImages for MainWindow's preview label. +Processes a queue of component lists. +""" + +from PyQt6 import QtCore, QtGui, uic +from PyQt6.QtCore import pyqtSignal, pyqtSlot from PIL import Image from PIL.ImageQt import ImageQt from queue import Queue, Empty @@ -26,8 +27,8 @@ class Worker(QtCore.QObject): super().__init__() self.core = core self.settings = settings - width = int(self.settings.value('outputWidth')) - height = int(self.settings.value('outputHeight')) + width = int(self.settings.value("outputWidth")) + height = int(self.settings.value("outputHeight")) self.queue = queue self.background = Checkerboard(width, height) @@ -35,10 +36,10 @@ class Worker(QtCore.QObject): @pyqtSlot(list) def createPreviewImage(self, components): dic = { - "components": components, + "components": components, } self.queue.put(dic) - log.debug('Preview thread id: {}'.format(int(QtCore.QThread.currentThreadId()))) + log.debug("Preview thread id: {}".format(int(QtCore.QThread.currentThreadId()))) @pyqtSlot() def process(self): @@ -49,31 +50,34 @@ class Worker(QtCore.QObject): self.queue.get(block=False) except Empty: continue - width = int(self.settings.value('outputWidth')) - height = int(self.settings.value('outputHeight')) - if self.background.width != width \ - or self.background.height != height: + width = int(self.settings.value("outputWidth")) + height = int(self.settings.value("outputHeight")) + if self.background.width != width or self.background.height != height: self.background = Checkerboard(width, height) frame = self.background.copy() - log.info('Creating new preview frame') + log.info("Creating new preview frame") components = nextPreviewInformation["components"] for component in reversed(components): try: component.lockSize(width, height) newFrame = component.previewRender() component.unlockSize() - frame = Image.alpha_composite( - frame, newFrame - ) + frame = Image.alpha_composite(frame, newFrame) except ValueError as e: - errMsg = "Bad frame returned by %s's preview renderer. " \ - "%s. New frame size was %s*%s; should be %s*%s." % ( - str(component), str(e).capitalize(), - newFrame.width, newFrame.height, - width, height + errMsg = ( + "Bad frame returned by %s's preview renderer. " + "%s. New frame size was %s*%s; should be %s*%s." + % ( + str(component), + str(e).capitalize(), + newFrame.width, + newFrame.height, + width, + height, ) + ) log.critical(errMsg) self.error.emit(errMsg) break diff --git a/src/gui/preview_win.py b/src/gui/preview_win.py index d910456..f52f8a3 100644 --- a/src/gui/preview_win.py +++ b/src/gui/preview_win.py @@ -1,18 +1,20 @@ -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt6 import QtCore, QtGui, QtWidgets import logging -log = logging.getLogger('AVP.Gui.PreviewWindow') +log = logging.getLogger("AVP.Gui.PreviewWindow") class PreviewWindow(QtWidgets.QLabel): - ''' - Paints the preview QLabel in MainWindow and maintains the aspect ratio - when the window is resized. - ''' + """ + Paints the preview QLabel in MainWindow and maintains the aspect ratio + when the window is resized. + """ + def __init__(self, parent, img): super().__init__() self.parent = parent - self.setFrameStyle(QtWidgets.QFrame.StyledPanel) + # FIXME + # self.setFrameStyle(QtWidgets.QFrame.StyledPanel) self.pixmap = QtGui.QPixmap(img) def paintEvent(self, event): @@ -21,12 +23,13 @@ class PreviewWindow(QtWidgets.QLabel): point = QtCore.QPoint(0, 0) scaledPix = self.pixmap.scaled( size, - QtCore.Qt.KeepAspectRatio, - transformMode=QtCore.Qt.SmoothTransformation) + QtCore.Qt.AspectRatioMode.KeepAspectRatio, + transformMode=QtCore.Qt.TransformationMode.SmoothTransformation, + ) # start painting the label from left upper corner - point.setX(int((size.width() - scaledPix.width())/2)) - point.setY(int((size.height() - scaledPix.height())/2)) + point.setX(int((size.width() - scaledPix.width()) / 2)) + point.setY(int((size.height() - scaledPix.height()) / 2)) painter.drawPixmap(point, scaledPix) def changePixmap(self, img): @@ -40,22 +43,16 @@ class PreviewWindow(QtWidgets.QLabel): i = self.parent.listWidget_componentList.currentRow() if i >= 0: component = self.parent.core.selectedComponents[i] - if not hasattr(component, 'previewClickEvent'): + if not hasattr(component, "previewClickEvent"): return - pos = (event.x(), event.y()) + qpoint = event.position().toPoint() + pos = (qpoint.x(), qpoint.y()) size = (self.width(), self.height()) butt = event.button() - log.info('Click event for #%s: %s button %s' % ( - i, pos, butt)) - component.previewClickEvent( - pos, size, butt - ) + log.info("Click event for #%s: %s button %s" % (i, pos, butt)) + component.previewClickEvent(pos, size, butt) @QtCore.pyqtSlot(str) def threadError(self, msg): - self.parent.showMessage( - msg=msg, - icon='Critical', - parent=self - ) - log.info('%', repr(self.parent)) + self.parent.showMessage(msg=msg, icon="Critical", parent=self) + log.info("%", repr(self.parent)) diff --git a/src/tests/__init__.py b/src/tests/__init__.py index 345bd96..e2d83e7 100644 --- a/src/tests/__init__.py +++ b/src/tests/__init__.py @@ -5,20 +5,22 @@ from ..core import Core def getTestDataPath(filename): - return os.path.join(Core.wd, 'tests', 'data', filename) + return os.path.join(Core.wd, "tests", "data", filename) def run(logFile): """Run Pytest, which then imports and runs all tests in this module.""" - os.environ["PYTEST_QT_API"] = "pyqt5" + os.environ["PYTEST_QT_API"] = "PyQt6" with open(logFile, "w") as f: # temporarily redirect stdout to a text file so we capture pytest's output sys.stdout = f try: - val = pytest.main([ - os.path.dirname(__file__), - "-s", # disable pytest's internal capturing of stdout etc. - ]) + val = pytest.main( + [ + os.path.dirname(__file__), + "-s", # disable pytest's internal capturing of stdout etc. + ] + ) finally: sys.stdout = sys.__stdout__ diff --git a/src/tests/test_commandline_export.py b/src/tests/test_commandline_export.py index 7f3530f..6126da7 100644 --- a/src/tests/test_commandline_export.py +++ b/src/tests/test_commandline_export.py @@ -7,11 +7,20 @@ from pytestqt import qtbot def test_commandline_classic_export(qtbot): - '''Run Qt event loop and create a video in the system /tmp or /temp''' + """Run Qt event loop and create a video in the system /tmp or /temp""" soundFile = getTestDataPath("test.ogg") outputDir = tempfile.mkdtemp(prefix="avp-test-") outputFilename = os.path.join(outputDir, "output.mp4") - sys.argv = ['', '-c', '0', 'classic', '-i', soundFile, '-o', outputFilename] + sys.argv = [ + "", + "-c", + "0", + "classic", + "-i", + soundFile, + "-o", + outputFilename, + ] command = Command() command.quit = lambda _: None @@ -19,10 +28,10 @@ def test_commandline_classic_export(qtbot): # Command object now has a video_thread Worker which is exporting the video with qtbot.waitSignal(command.worker.videoCreated, timeout=10000): - ''' + """ Wait until videoCreated is emitted by the video_thread Worker or until 10 second timeout has passed - ''' + """ print(f"Test Video created at {outputFilename}") assert os.path.exists(outputFilename) diff --git a/src/tests/test_commandline_parser.py b/src/tests/test_commandline_parser.py index 0813e00..5d1232b 100644 --- a/src/tests/test_commandline_parser.py +++ b/src/tests/test_commandline_parser.py @@ -5,28 +5,28 @@ from ..command import Command def test_commandline_help(): command = Command() - sys.argv = ['', '--help'] + sys.argv = ["", "--help"] with pytest.raises(SystemExit): command.parseArgs() def test_commandline_help_if_bad_args(): command = Command() - sys.argv = ['', '--junk'] + sys.argv = ["", "--junk"] with pytest.raises(SystemExit): command.parseArgs() def test_commandline_launches_gui_if_debug(): command = Command() - sys.argv = ['', '--debug'] + sys.argv = ["", "--debug"] mode = command.parseArgs() assert mode == "GUI" def test_commandline_launches_gui_if_debug_with_project(): command = Command() - sys.argv = ['', 'test', '--debug'] + sys.argv = ["", "test", "--debug"] mode = command.parseArgs() assert mode == "GUI" @@ -34,11 +34,12 @@ def test_commandline_launches_gui_if_debug_with_project(): def test_commandline_tries_to_export(): command = Command() didCallFunction = False + def captureFunction(*args): nonlocal didCallFunction didCallFunction = True - sys.argv = ['', '-c', '0', 'classic', '-i', '_', '-o', '_'] + sys.argv = ["", "-c", "0", "classic", "-i", "_", "-o", "_"] command.createAudioVisualization = captureFunction command.parseArgs() assert didCallFunction diff --git a/src/tests/test_core_init.py b/src/tests/test_core_init.py index 438f7fe..950dc13 100644 --- a/src/tests/test_core_init.py +++ b/src/tests/test_core_init.py @@ -4,15 +4,15 @@ from ..core import Core def test_component_names(): core = Core() assert core.compNames == [ - 'Classic Visualizer', - 'Color', + "Classic Visualizer", + "Color", "Conway's Game of Life", - 'Image', - 'Sound', - 'Spectrum', - 'Title Text', - 'Video', - 'Waveform', + "Image", + "Sound", + "Spectrum", + "Title Text", + "Video", + "Waveform", ] diff --git a/src/toolkit/common.py b/src/toolkit/common.py index 2e800eb..e35aba2 100644 --- a/src/toolkit/common.py +++ b/src/toolkit/common.py @@ -1,7 +1,8 @@ -''' - Common functions -''' -from PyQt5 import QtWidgets +""" +Common functions +""" + +from PyQt6 import QtWidgets import string import os import sys @@ -11,43 +12,38 @@ from copy import copy from collections import OrderedDict -log = logging.getLogger('AVP.Toolkit.Common') +log = logging.getLogger("AVP.Toolkit.Common") class blockSignals: - ''' - Context manager to temporarily block list of QtWidgets from updating, - and guarantee restoring the previous state afterwards. - ''' + """ + Context manager to temporarily block list of QtWidgets from updating, + and guarantee restoring the previous state afterwards. + """ + def __init__(self, widgets): if type(widgets) is dict: self.widgets = concatDictVals(widgets) else: - self.widgets = ( - widgets if hasattr(widgets, '__iter__') - else [widgets] - ) + self.widgets = widgets if hasattr(widgets, "__iter__") else [widgets] def __enter__(self): log.verbose( - 'Blocking signals for %s', - ", ".join([ - str(w.__class__.__name__) for w in self.widgets - ]) + "Blocking signals for %s", + ", ".join([str(w.__class__.__name__) for w in self.widgets]), ) self.oldStates = [w.signalsBlocked() for w in self.widgets] for w in self.widgets: w.blockSignals(True) def __exit__(self, *args): - log.verbose( - 'Resetting blockSignals to %s', str(bool(sum(self.oldStates)))) + log.verbose("Resetting blockSignals to %s", str(bool(sum(self.oldStates)))) for w, state in zip(self.widgets, self.oldStates): w.blockSignals(state) def concatDictVals(d): - '''Concatenates all values in given dict into one list.''' + """Concatenates all values in given dict into one list.""" key, value = d.popitem() d[key] = value final = copy(value) @@ -60,22 +56,22 @@ def concatDictVals(d): def badName(name): - '''Returns whether a name contains non-alphanumeric chars''' + """Returns whether a name contains non-alphanumeric chars""" return any([letter in string.punctuation for letter in name]) def alphabetizeDict(dictionary): - '''Alphabetizes a dict into OrderedDict ''' + """Alphabetizes a dict into OrderedDict""" return OrderedDict(sorted(dictionary.items(), key=lambda t: t[0])) def presetToString(dictionary): - '''Returns string repr of a preset''' + """Returns string repr of a preset""" return repr(alphabetizeDict(dictionary)) def presetFromString(string): - '''Turns a string repr of OrderedDict into a regular dict''' + """Turns a string repr of OrderedDict into a regular dict""" return dict(eval(string)) @@ -86,19 +82,21 @@ def appendUppercase(lst): def pipeWrapper(func): - '''A decorator to insert proper kwargs into Popen objects.''' + """A decorator to insert proper kwargs into Popen objects.""" + def pipeWrapper(commandList, **kwargs): - if sys.platform == 'win32': + if sys.platform == "win32": # Stop CMD window from appearing on Windows startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - kwargs['startupinfo'] = startupinfo + kwargs["startupinfo"] = startupinfo - if 'bufsize' not in kwargs: - kwargs['bufsize'] = 10**8 - if 'stdin' not in kwargs: - kwargs['stdin'] = subprocess.DEVNULL + if "bufsize" not in kwargs: + kwargs["bufsize"] = 10**8 + if "stdin" not in kwargs: + kwargs["stdin"] = subprocess.DEVNULL return func(commandList, **kwargs) + return pipeWrapper @@ -113,6 +111,7 @@ def disableWhenEncoding(func): return else: return func(self, *args, **kwargs) + return decorator @@ -122,13 +121,14 @@ def disableWhenOpeningProject(func): return else: return func(self, *args, **kwargs) + return decorator def rgbFromString(string): - '''Turns an RGB string like "255, 255, 255" into a tuple''' + """Turns an RGB string like "255, 255, 255" into a tuple""" try: - tup = tuple([int(i) for i in string.split(',')]) + tup = tuple([int(i) for i in string.split(",")]) if len(tup) != 3: raise ValueError for i in tup: @@ -141,42 +141,42 @@ def rgbFromString(string): 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)) + return "Traceback:\n%s" % "\n".join(traceback.format_tb(tb)) def connectWidget(widget, func): if type(widget) == QtWidgets.QLineEdit: widget.textChanged.connect(func) - elif type(widget) == QtWidgets.QSpinBox \ - or type(widget) == QtWidgets.QDoubleSpinBox: + elif type(widget) == QtWidgets.QSpinBox or type(widget) == QtWidgets.QDoubleSpinBox: widget.valueChanged.connect(func) elif type(widget) == QtWidgets.QCheckBox: widget.stateChanged.connect(func) elif type(widget) == QtWidgets.QComboBox: widget.currentIndexChanged.connect(func) else: - log.warning('Failed to connect %s ', str(widget.__class__.__name__)) + log.warning("Failed to connect %s ", str(widget.__class__.__name__)) return False return True def setWidgetValue(widget, val): - '''Generic setValue method for use with any typical QtWidget''' - log.verbose('Setting %s to %s' % (str(widget.__class__.__name__), val)) + """Generic setValue method for use with any typical QtWidget""" + log.verbose("Setting %s to %s" % (str(widget.__class__.__name__), val)) if type(widget) == QtWidgets.QLineEdit: widget.setText(val) - elif type(widget) == QtWidgets.QSpinBox \ - or type(widget) == QtWidgets.QDoubleSpinBox: + 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) else: - log.warning('Failed to set %s ', str(widget.__class__.__name__)) + log.warning("Failed to set %s ", str(widget.__class__.__name__)) return False return True @@ -184,8 +184,7 @@ def setWidgetValue(widget, val): def getWidgetValue(widget): if type(widget) == QtWidgets.QLineEdit: return widget.text() - elif type(widget) == QtWidgets.QSpinBox \ - or type(widget) == QtWidgets.QDoubleSpinBox: + elif type(widget) == QtWidgets.QSpinBox or type(widget) == QtWidgets.QDoubleSpinBox: return widget.value() elif type(widget) == QtWidgets.QCheckBox: return widget.isChecked() diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py index ff06469..5aedff3 100644 --- a/src/toolkit/ffmpeg.py +++ b/src/toolkit/ffmpeg.py @@ -1,6 +1,7 @@ -''' - Tools for using ffmpeg -''' +""" +Tools for using ffmpeg +""" + import numpy import sys import os @@ -14,67 +15,74 @@ from .. import core from .common import checkOutput, pipeWrapper -log = logging.getLogger('AVP.Toolkit.Ffmpeg') +log = logging.getLogger("AVP.Toolkit.Ffmpeg") class FfmpegVideo: - '''Opens a pipe to ffmpeg and stores a buffer of raw video frames.''' + """Opens a pipe to ffmpeg and stores a buffer of raw video frames.""" # error from the thread used to fill the buffer threadError = None def __init__(self, **kwargs): mandatoryArgs = [ - 'inputPath', - 'filter_', - 'width', - 'height', - 'frameRate', # frames per second - 'chunkSize', # number of bytes in one frame - 'parent', # mainwindow object - 'component', # component object + "inputPath", + "filter_", + "width", + "height", + "frameRate", # frames per second + "chunkSize", # number of bytes in one frame + "parent", # mainwindow object + "component", # component object ] for arg in mandatoryArgs: setattr(self, arg, kwargs[arg]) self.frameNo = -1 - self.currentFrame = 'None' + self.currentFrame = "None" self.map_ = None - if 'loopVideo' in kwargs and kwargs['loopVideo']: - self.loopValue = '-1' + if "loopVideo" in kwargs and kwargs["loopVideo"]: + self.loopValue = "-1" else: - self.loopValue = '0' - if 'filter_' in kwargs: - if kwargs['filter_'][0] != '-filter_complex': - kwargs['filter_'].insert(0, '-filter_complex') + self.loopValue = "0" + if "filter_" in kwargs: + if kwargs["filter_"][0] != "-filter_complex": + kwargs["filter_"].insert(0, "-filter_complex") else: - kwargs['filter_'] = None + kwargs["filter_"] = None self.command = [ core.Core.FFMPEG_BIN, - '-thread_queue_size', '512', - '-r', str(self.frameRate), - '-stream_loop', str(self.loopValue), - '-i', self.inputPath, - '-f', 'image2pipe', - '-pix_fmt', 'rgba', + "-thread_queue_size", + "512", + "-r", + str(self.frameRate), + "-stream_loop", + str(self.loopValue), + "-i", + self.inputPath, + "-f", + "image2pipe", + "-pix_fmt", + "rgba", ] - if type(kwargs['filter_']) is list: - self.command.extend( - kwargs['filter_'] - ) - self.command.extend([ - '-codec:v', 'rawvideo', '-', - ]) + if type(kwargs["filter_"]) is list: + self.command.extend(kwargs["filter_"]) + self.command.extend( + [ + "-codec:v", + "rawvideo", + "-", + ] + ) self.frameBuffer = PriorityQueue() self.frameBuffer.maxsize = self.frameRate self.finishedFrames = {} self.thread = threading.Thread( - target=self.fillBuffer, - name='FFmpeg Frame-Fetcher' + target=self.fillBuffer, name="FFmpeg Frame-Fetcher" ) self.thread.daemon = True self.thread.start() @@ -91,22 +99,29 @@ class FfmpegVideo: def fillBuffer(self): from ..component import ComponentError + if core.Core.logEnabled: logFilename = os.path.join( - core.Core.logDir, 'render_%s.log' % str(self.component.compPos) + core.Core.logDir, "render_%s.log" % str(self.component.compPos) ) - log.debug('Creating ffmpeg process (log at %s)', logFilename) - with open(logFilename, 'w') as logf: - logf.write(" ".join(self.command) + '\n\n') - with open(logFilename, 'a') as logf: + log.debug("Creating ffmpeg process (log at %s)", logFilename) + with open(logFilename, "w") as logf: + logf.write(" ".join(self.command) + "\n\n") + with open(logFilename, "a") as logf: self.pipe = openPipe( - self.command, stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, stderr=logf, bufsize=10**8 + self.command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=logf, + bufsize=10**8, ) else: self.pipe = openPipe( - self.command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, bufsize=10**8 + self.command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=10**8, ) while True: @@ -117,12 +132,13 @@ class FfmpegVideo: # If we run out of frames, use the last good frame and loop. try: if len(self.currentFrame) == 0: - self.frameBuffer.put((self.frameNo-1, self.lastFrame)) + self.frameBuffer.put((self.frameNo - 1, self.lastFrame)) continue except AttributeError: FfmpegVideo.threadError = ComponentError( - self.component, 'video', - "Video seemed playable but wasn't." + self.component, + "video", + "Video seemed playable but wasn't.", ) break @@ -130,11 +146,12 @@ class FfmpegVideo: self.currentFrame = self.pipe.stdout.read(self.chunkSize) except ValueError as e: if str(e) == "PyMemoryView_FromBuffer(): info->buf must not be NULL": - log.debug("Ignored 'info->buf must not be NULL' error from FFmpeg pipe") + log.debug( + "Ignored 'info->buf must not be NULL' error from FFmpeg pipe" + ) return else: - FfmpegVideo.threadError = ComponentError( - self.component, 'video') + FfmpegVideo.threadError = ComponentError(self.component, "video") if len(self.currentFrame) != 0: self.frameBuffer.put((self.frameNo, self.currentFrame)) @@ -153,19 +170,17 @@ def closePipe(pipe): def findFfmpeg(): if sys.platform == "win32": - bin = 'ffmpeg.exe' + bin = "ffmpeg.exe" else: - bin = 'ffmpeg' + bin = "ffmpeg" - if getattr(sys, 'frozen', False): + if getattr(sys, "frozen", False): # The application is frozen bin = os.path.join(core.Core.wd, bin) with open(os.devnull, "w") as f: try: - checkOutput( - [bin, '-version'], stderr=f - ) + checkOutput([bin, "-version"], stderr=f) except (subprocess.CalledProcessError, FileNotFoundError): bin = "" @@ -173,9 +188,9 @@ def findFfmpeg(): def createFfmpegCommand(inputFile, outputFile, components, duration=-1): - ''' - Constructs the major ffmpeg command used to export the video - ''' + """ + Constructs the major ffmpeg command used to export the video + """ if duration == -1: duration = getAudioDuration(inputFile) safeDuration = "{0:.3f}".format(duration - 0.05) # used by filters @@ -183,31 +198,33 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1): Core = core.Core # Test if user has libfdk_aac - encoders = checkOutput( - "%s -encoders -hide_banner" % Core.FFMPEG_BIN, shell=True - ) + encoders = checkOutput("%s -encoders -hide_banner" % Core.FFMPEG_BIN, shell=True) encoders = encoders.decode("utf-8") - acodec = Core.settings.value('outputAudioCodec') + acodec = Core.settings.value("outputAudioCodec") options = Core.encoderOptions - containerName = Core.settings.value('outputContainer') - vcodec = Core.settings.value('outputVideoCodec') - vbitrate = str(Core.settings.value('outputVideoBitrate'))+'k' - acodec = Core.settings.value('outputAudioCodec') - abitrate = str(Core.settings.value('outputAudioBitrate'))+'k' - - for cont in options['containers']: - if cont['name'] == containerName: - container = cont['container'] + containerName = Core.settings.value("outputContainer") + vcodec = Core.settings.value("outputVideoCodec") + vbitrate = str(Core.settings.value("outputVideoBitrate")) + "k" + acodec = Core.settings.value("outputAudioCodec") + abitrate = str(Core.settings.value("outputAudioBitrate")) + "k" + + for cont in options["containers"]: + if cont["name"] == containerName: + container = cont["container"] break - vencoders = options['video-codecs'][vcodec] - aencoders = options['audio-codecs'][acodec] + vencoders = options["video-codecs"][vcodec] + aencoders = options["audio-codecs"][acodec] def error(): nonlocal encoders, encoder - log.critical("Selected encoder (%s) is not supported by Ffmpeg. The supported encoders are: %s", encoder, encoders) + log.critical( + "Selected encoder (%s) is not supported by Ffmpeg. The supported encoders are: %s", + encoder, + encoders, + ) return [] for encoder in vencoders: @@ -226,57 +243,75 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1): ffmpegCommand = [ Core.FFMPEG_BIN, - '-thread_queue_size', '512', - '-y', # overwrite the output file if it already exists. - + "-thread_queue_size", + "512", + "-y", # overwrite the output file if it already exists. # INPUT VIDEO - '-f', 'rawvideo', - '-vcodec', 'rawvideo', - '-s', f'{Core.settings.value("outputWidth")}x{Core.settings.value("outputHeight")}', - '-pix_fmt', 'rgba', - '-r', str(Core.settings.value('outputFrameRate')), - '-t', duration, - '-an', # the video input has no sound - '-i', '-', # the video input comes from a pipe - + "-f", + "rawvideo", + "-vcodec", + "rawvideo", + "-s", + f'{Core.settings.value("outputWidth")}x{Core.settings.value("outputHeight")}', + "-pix_fmt", + "rgba", + "-r", + str(Core.settings.value("outputFrameRate")), + "-t", + duration, + "-an", # the video input has no sound + "-i", + "-", # the video input comes from a pipe # INPUT SOUND - '-t', duration, - '-i', inputFile + "-t", + duration, + "-i", + inputFile, ] - extraAudio = [ - comp.audio for comp in components - if 'audio' in comp.properties() - ] + extraAudio = [comp.audio for comp in components if "audio" in comp.properties()] segment = createAudioFilterCommand(extraAudio, safeDuration) ffmpegCommand.extend(segment) # Map audio from the filters or the single audio input, and map video from the pipe - ffmpegCommand.extend([ - '-map', '0:v', - '-map', '[a]' if segment else '1:a', - ]) - - ffmpegCommand.extend([ - # OUTPUT - '-vcodec', vencoder, - '-acodec', aencoder, - '-b:v', vbitrate, - '-b:a', abitrate, - '-pix_fmt', Core.settings.value('outputVideoFormat'), - '-preset', Core.settings.value('outputPreset'), - '-f', container - ]) - - if acodec == 'aac': - ffmpegCommand.append('-strict') - ffmpegCommand.append('-2') + ffmpegCommand.extend( + [ + "-map", + "0:v", + "-map", + "[a]" if segment else "1:a", + ] + ) + + ffmpegCommand.extend( + [ + # OUTPUT + "-vcodec", + vencoder, + "-acodec", + aencoder, + "-b:v", + vbitrate, + "-b:a", + abitrate, + "-pix_fmt", + Core.settings.value("outputVideoFormat"), + "-preset", + Core.settings.value("outputPreset"), + "-f", + container, + ] + ) + + if acodec == "aac": + ffmpegCommand.append("-strict") + ffmpegCommand.append("-2") ffmpegCommand.append(outputFile) return ffmpegCommand def createAudioFilterCommand(extraAudio, duration): - '''Add extra inputs and any needed filters to the main ffmpeg command.''' + """Add extra inputs and any needed filters to the main ffmpeg command.""" # NOTE: Global filters are currently hard-coded here for debugging use globalFilters = 0 # increase to add global filters @@ -288,21 +323,23 @@ def createAudioFilterCommand(extraAudio, duration): extraFilters = {} for streamNo, params in enumerate(reversed(extraAudio)): extraInputFile, params = params - ffmpegCommand.extend([ - '-t', duration, - # Tell ffmpeg about shorter clips (seemingly not needed) - # streamDuration = getAudioDuration(extraInputFile) - # if streamDuration and streamDuration > float(safeDuration) - # else "{0:.3f}".format(streamDuration), - '-i', extraInputFile - ]) + ffmpegCommand.extend( + [ + "-t", + duration, + # Tell ffmpeg about shorter clips (seemingly not needed) + # streamDuration = getAudioDuration(extraInputFile) + # if streamDuration and streamDuration > float(safeDuration) + # else "{0:.3f}".format(streamDuration), + "-i", + extraInputFile, + ] + ) # Construct dataset of extra filters we'll need to add later for ffmpegFilter in params: if streamNo + 2 not in extraFilters: extraFilters[streamNo + 2] = [] - extraFilters[streamNo + 2].append(( - ffmpegFilter, params[ffmpegFilter] - )) + extraFilters[streamNo + 2].append((ffmpegFilter, params[ffmpegFilter])) # Start creating avfilters! Popen-style, so don't use semicolons; extraFilterCommand = [] @@ -318,63 +355,73 @@ def createAudioFilterCommand(extraAudio, duration): extraFilters[streamNo + 1] = [] # Also filter the primary audio track extraFilters[1] = [] - tmpInputs = { - streamNo: globalFilters - 1 - for streamNo in extraFilters - } + tmpInputs = {streamNo: globalFilters - 1 for streamNo in extraFilters} # Add the global filters! # NOTE: list length must = globalFilters, currently hardcoded if tmpInputs: - extraFilterCommand.extend([ - '[%s:a] ashowinfo [%stmp0]' % ( - str(streamNo), - str(streamNo) - ) - for streamNo in tmpInputs - ]) + extraFilterCommand.extend( + [ + "[%s:a] ashowinfo [%stmp0]" % (str(streamNo), str(streamNo)) + for streamNo in tmpInputs + ] + ) # Now add the per-stream filters! for streamNo, paramList in extraFilters.items(): for param in paramList: - source = '[%s:a]' % str(streamNo) \ - if tmpInputs[streamNo] == -1 else \ - '[%stmp%s]' % ( - str(streamNo), str(tmpInputs[streamNo]) - ) + source = ( + "[%s:a]" % str(streamNo) + if tmpInputs[streamNo] == -1 + else "[%stmp%s]" % (str(streamNo), str(tmpInputs[streamNo])) + ) tmpInputs[streamNo] = tmpInputs[streamNo] + 1 extraFilterCommand.append( - '%s %s%s [%stmp%s]' % ( - source, param[0], param[1], str(streamNo), - str(tmpInputs[streamNo]) + "%s %s%s [%stmp%s]" + % ( + source, + param[0], + param[1], + str(streamNo), + str(tmpInputs[streamNo]), ) ) # Join all the filters together and combine into 1 stream - extraFilterCommand = "; ".join(extraFilterCommand) + '; ' \ - if tmpInputs else '' - ffmpegCommand.extend([ - '-filter_complex', - extraFilterCommand + - '%s amix=inputs=%s:duration=first [a]' - % ( - "".join([ - '[%stmp%s]' % (str(i), tmpInputs[i]) - if i in extraFilters else '[%s:a]' % str(i) - for i in range(1, len(extraAudio) + 2) - ]), - str(len(extraAudio) + 1) - ), - ]) + extraFilterCommand = "; ".join(extraFilterCommand) + "; " if tmpInputs else "" + ffmpegCommand.extend( + [ + "-filter_complex", + extraFilterCommand + + "%s amix=inputs=%s:duration=first [a]" + % ( + "".join( + [ + ( + "[%stmp%s]" % (str(i), tmpInputs[i]) + if i in extraFilters + else "[%s:a]" % str(i) + ) + for i in range(1, len(extraAudio) + 2) + ] + ), + str(len(extraAudio) + 1), + ), + ] + ) return ffmpegCommand def testAudioStream(filename): - '''Test if an audio stream definitely exists''' + """Test if an audio stream definitely exists""" audioTestCommand = [ core.Core.FFMPEG_BIN, - '-i', filename, - '-vn', '-f', 'null', '-' + "-i", + filename, + "-vn", + "-f", + "null", + "-", ] try: checkOutput(audioTestCommand, stderr=subprocess.DEVNULL) @@ -385,8 +432,8 @@ def testAudioStream(filename): def getAudioDuration(filename): - '''Try to get duration of audio file as float, or False if not possible''' - command = [core.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=subprocess.STDOUT) @@ -397,17 +444,17 @@ def getAudioDuration(filename): return False try: - info = fileInfo.decode("utf-8").split('\n') + info = fileInfo.decode("utf-8").split("\n") except UnicodeDecodeError as e: - log.error('Unicode error:', str(e)) + log.error("Unicode error:", str(e)) return False for line in info: - if 'Duration' in line: - d = line.split(',')[0] - d = d.split(' ')[3] - d = d.split(':') - duration = float(d[0])*3600 + float(d[1])*60 + float(d[2]) + if "Duration" in line: + d = line.split(",")[0] + d = d.split(" ")[3] + d = d.split(":") + duration = float(d[0]) * 3600 + float(d[1]) * 60 + float(d[2]) break else: # String not found in output @@ -416,10 +463,10 @@ def getAudioDuration(filename): def readAudioFile(filename, videoWorker): - ''' - Creates the completeAudioArray given to components - and used to draw the classic visualizer. - ''' + """ + Creates the completeAudioArray given to components + and used to draw the classic visualizer. + """ duration = getAudioDuration(filename) if not duration: log.error(f"Audio file {filename} doesn't exist or unreadable.") @@ -427,15 +474,23 @@ def readAudioFile(filename, videoWorker): command = [ core.Core.FFMPEG_BIN, - '-i', filename, - '-f', 's16le', - '-acodec', 'pcm_s16le', - '-ar', '44100', # ouput will have 44100 Hz - '-ac', '1', # mono (set to '2' for stereo) - '-'] + "-i", + filename, + "-f", + "s16le", + "-acodec", + "pcm_s16le", + "-ar", + "44100", # ouput will have 44100 Hz + "-ac", + "1", # mono (set to '2' for stereo) + "-", + ] in_pipe = openPipe( command, - stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=10**8 + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=10**8, ) completeAudioArray = numpy.empty(0, dtype="int16") @@ -447,18 +502,18 @@ def readAudioFile(filename, videoWorker): return # read 2 seconds of audio progress += 4 - raw_audio = in_pipe.stdout.read(88200*4) + raw_audio = in_pipe.stdout.read(88200 * 4) if len(raw_audio) == 0: break - audio_array = numpy.fromstring(raw_audio, dtype="int16") + audio_array = numpy.frombuffer(raw_audio, dtype="int16") completeAudioArray = numpy.append(completeAudioArray, audio_array) - percent = int(100*(progress/duration)) + percent = int(100 * (progress / duration)) if percent >= 100: percent = 100 if lastPercent != percent: - string = 'Loading audio file: '+str(percent)+'%' + string = "Loading audio file: " + str(percent) + "%" videoWorker.progressBarSetText.emit(string) videoWorker.progressBarUpdate.emit(percent) @@ -468,25 +523,23 @@ def readAudioFile(filename, videoWorker): in_pipe.wait() # add 0s the end - completeAudioArrayCopy = numpy.zeros( - len(completeAudioArray) + 44100, dtype="int16") - completeAudioArrayCopy[:len(completeAudioArray)] = completeAudioArray + completeAudioArrayCopy = numpy.zeros(len(completeAudioArray) + 44100, dtype="int16") + completeAudioArrayCopy[: len(completeAudioArray)] = completeAudioArray completeAudioArray = completeAudioArrayCopy return (completeAudioArray, duration) -def exampleSound( - style='white', extra='apulsator=offset_l=0.35:offset_r=0.67'): - '''Help generate an example sound for use in creating a preview''' +def exampleSound(style="white", extra="apulsator=offset_l=0.35:offset_r=0.67"): + """Help generate an example sound for use in creating a preview""" - if style == 'white': - src = '-2+random(0)' - elif style == 'freq': - src = 'sin(1000*t*PI*t)' - elif style == 'wave': - src = 'sin(random(0)*2*PI*t)*tan(random(0)*2*PI*t)' - elif style == 'stereo': - src = '0.1*sin(2*PI*(360-2.5/2)*t) | 0.1*sin(2*PI*(360+2.5/2)*t)' + if style == "white": + src = "-2+random(0)" + elif style == "freq": + src = "sin(1000*t*PI*t)" + elif style == "wave": + src = "sin(random(0)*2*PI*t)*tan(random(0)*2*PI*t)" + elif style == "stereo": + src = "0.1*sin(2*PI*(360-2.5/2)*t) | 0.1*sin(2*PI*(360+2.5/2)*t)" - return "aevalsrc='%s', %s%s" % (src, extra, ', ' if extra else '') + return "aevalsrc='%s', %s%s" % (src, extra, ", " if extra else "") diff --git a/src/toolkit/frame.py b/src/toolkit/frame.py index 520bd43..94537a6 100644 --- a/src/toolkit/frame.py +++ b/src/toolkit/frame.py @@ -1,25 +1,27 @@ -''' - Common tools for drawing compatible frames in a Component's frameRender() -''' -from PyQt5 import QtGui +""" +Common tools for drawing compatible frames in a Component's frameRender() +""" + +from PyQt6 import QtGui from PIL import Image from PIL.ImageQt import ImageQt +from PyQt6 import QtCore import sys import os import math import logging - from .. import core -log = logging.getLogger('AVP.Toolkit.Frame') +log = logging.getLogger("AVP.Toolkit.Frame") class FramePainter(QtGui.QPainter): - ''' - A QPainter for a blank frame, which can be converted into a - Pillow image with finalize() - ''' + """ + A QPainter for a blank frame, which can be converted into a + Pillow image with finalize() + """ + def __init__(self, width, height): image = BlankFrame(width, height) log.debug("Creating QImage from PIL image object") @@ -34,21 +36,33 @@ class FramePainter(QtGui.QPainter): def finalize(self): log.verbose("Finalizing FramePainter") + buffer = QtCore.QBuffer() + buffer.open(QtCore.QBuffer.OpenModeFlag.ReadWrite) + self.image.save(buffer, "PNG") + import io + + frame = Image.open(io.BytesIO(buffer.data())) + buffer.close() + self.end() + return frame imBytes = self.image.bits().asstring(self.image.byteCount()) - frame = Image.frombytes( - 'RGBA', (self.image.width(), self.image.height()), imBytes + frame = Image.frombytes( + "RGBA", (self.image.width(), self.image.height()), imBytes ) self.end() return frame class PaintColor(QtGui.QColor): - '''Reverse the painter colour if the hardware stores RGB values backward''' + """ + Subclass of QtGui.QColor with an added scale() method + Previously this class reversed the painter colour to solve + hardware issues related to endianness, + but Qt appears to deal with this itself nowadays + """ + def __init__(self, r, g, b, a=255): - if sys.byteorder == 'big': - super().__init__(r, g, b, a) - else: - super().__init__(b, g, r, a) + super().__init__(r, g, b, a) def scale(scalePercent, width, height, returntype=None): @@ -63,7 +77,8 @@ def scale(scalePercent, width, height, returntype=None): def defaultSize(framefunc): - '''Makes width/height arguments optional''' + """Makes width/height arguments optional""" + def decorator(*args): if len(args) < 2: newArgs = list(args) @@ -75,6 +90,7 @@ def defaultSize(framefunc): newArgs.insert(0, width) args = tuple(newArgs) return framefunc(*args) + return decorator @@ -84,21 +100,18 @@ def FloodFrame(width, height, RgbaTuple): @defaultSize def BlankFrame(width, height): - '''The base frame used by each component to start drawing.''' + """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. - TODO: Would be cool to generate this image with numpy instead. - ''' - log.debug('Creating new %s*%s checkerboard' % (width, height)) + """ + A checkerboard to represent transparency to the user. + """ + # TODO: Would be cool to generate this image with numpy instead. + log.debug("Creating new %s*%s checkerboard" % (width, height)) image = FloodFrame(1920, 1080, (0, 0, 0, 0)) - image.paste(Image.open( - os.path.join(core.Core.wd, 'gui', "background.png")), - (0, 0) - ) + image.paste(Image.open(os.path.join(core.Core.wd, "gui", "background.png")), (0, 0)) image = image.resize((width, height)) return image diff --git a/src/video_thread.py b/src/video_thread.py index 70e4d5d..5d72409 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -1,4 +1,4 @@ -''' +""" Worker thread created to export a video. It has a slot to begin export using an input file, output path, and component list. @@ -6,9 +6,10 @@ Signals are emitted to update MainWindow's progress bar, detail text, and previe A Command object takes the place of MainWindow while in commandline mode. Export can be cancelled with cancel() -''' -from PyQt5 import QtCore, QtGui -from PyQt5.QtCore import pyqtSignal, pyqtSlot +""" + +from PyQt6 import QtCore, QtGui +from PyQt6.QtCore import pyqtSignal, pyqtSlot from PIL import Image from PIL.ImageQt import ImageQt import numpy @@ -22,8 +23,10 @@ import logging from .component import ComponentError from .toolkit.frame import Checkerboard from .toolkit.ffmpeg import ( - openPipe, readAudioFile, - getAudioDuration, createFfmpegCommand + openPipe, + readAudioFile, + getAudioDuration, + createFfmpegCommand, ) @@ -32,7 +35,7 @@ log = logging.getLogger("AVP.VideoThread") class Worker(QtCore.QObject): - imageCreated = pyqtSignal('QImage') + imageCreated = pyqtSignal("QImage") videoCreated = pyqtSignal() progressBarUpdate = pyqtSignal(int) progressBarSetText = pyqtSignal(str) @@ -61,31 +64,34 @@ class Worker(QtCore.QObject): self.inputFile, self.outputFile, self.components, duration ) except sp.CalledProcessError as e: - #FIXME video_thread should own this error signal, not components - self.components[0]._error.emit("Ffmpeg could not be found. Is it installed?", str(e)) + # FIXME video_thread should own this error signal, not components + self.components[0]._error.emit( + "Ffmpeg could not be found. Is it installed?", str(e) + ) self.error = True return - + if not ffmpegCommand: - #FIXME video_thread should own this error signal, not components - self.components[0]._error.emit("The FFmpeg command could not be generated.", "") - log.critical("Cancelling render process due to failure while generating the ffmpeg command.") + # FIXME video_thread should own this error signal, not components + self.components[0]._error.emit( + "The FFmpeg command could not be generated.", "" + ) + log.critical( + "Cancelling render process due to failure while generating the ffmpeg command." + ) self.failExport() return return ffmpegCommand def determineAudioLength(self): - ''' + """ Returns audio length which determines length of final video, or False if failure occurs - ''' - if any([ - True if 'pcm' in comp.properties() else False - for comp in self.components - ]): + """ + if any( + [True if "pcm" in comp.properties() else False for comp in self.components] + ): self.progressBarSetText.emit("Loading audio file...") - audioFileTraits = readAudioFile( - self.inputFile, self - ) + audioFileTraits = readAudioFile(self.inputFile, self) if audioFileTraits is None: self.cancelExport() return False @@ -95,25 +101,27 @@ class Worker(QtCore.QObject): duration = getAudioDuration(self.inputFile) self.completeAudioArray = [] self.audioArrayLen = int( - ((duration * self.hertz) + - self.hertz) - self.sampleSize) + ((duration * self.hertz) + self.hertz) - self.sampleSize + ) return duration def preFrameRender(self): - ''' + """ Initializes components that need to pre-compute stuff. Also prerenders "static" components like text and merges them if possible - ''' + """ self.staticComponents = {} # Call preFrameRender on each component canceledByComponent = False - initText = ", ".join([ - "%s) %s" % (num, str(component)) - for num, component in enumerate(reversed(self.components)) - ]) - print('Loaded Components:', initText) - log.info('Calling preFrameRender for %s', initText) + initText = ", ".join( + [ + "%s) %s" % (num, str(component)) + for num, component in enumerate(reversed(self.components)) + ] + ) + print("Loaded Components:", initText) + log.info("Calling preFrameRender for %s", initText) for compNo, comp in enumerate(reversed(self.components)): try: comp.preFrameRender( @@ -122,80 +130,85 @@ class Worker(QtCore.QObject): audioArrayLen=self.audioArrayLen, sampleSize=self.sampleSize, progressBarUpdate=self.progressBarUpdate, - progressBarSetText=self.progressBarSetText + progressBarSetText=self.progressBarSetText, ) except ComponentError: log.warning( - '#%s %s encountered an error in its preFrameRender method', + "#%s %s encountered an error in its preFrameRender method", compNo, - comp + comp, ) compProps = comp.properties() - if 'error' in compProps or comp._lockedError is not None: + if "error" in compProps or comp._lockedError is not None: self.cancel() self.canceled = True canceledByComponent = True - compError = comp.error() \ - if type(comp.error()) is tuple else (comp.error(), '') + compError = ( + comp.error() if type(comp.error()) is tuple else (comp.error(), "") + ) errMsg = ( - "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), - comp.name, - compError[0] - ) + "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), comp.name, compError[0]) ) log.error(errMsg) comp._error.emit(errMsg, compError[1]) break - if 'static' in compProps: - log.info('Saving static frame from #%s %s', compNo, comp) - self.staticComponents[compNo] = \ - comp.frameRender(0).copy() + if "static" in compProps: + log.info("Saving static frame from #%s %s", compNo, comp) + self.staticComponents[compNo] = comp.frameRender(0).copy() # Check if any errors occured log.debug("Checking if a component wishes to cancel the export...") if self.canceled: if canceledByComponent: log.error( - 'Export cancelled by component #%s (%s): %s', + "Export cancelled by component #%s (%s): %s", compNo, comp.name, - 'No message.' if comp.error() is None else ( - comp.error() if type(comp.error()) is str - else comp.error()[0] - ) + ( + "No message." + if comp.error() is None + else ( + comp.error() + if type(comp.error()) is str + else comp.error()[0] + ) + ), ) self.cancelExport() - + # Merge static frames that can be merged to reduce workload def mergeConsecutiveStaticComponentFrames(self): log.info("Merging consecutive static component frames") for compNo in range(len(self.components)): - if compNo not in self.staticComponents \ - or compNo + 1 not in self.staticComponents: + if ( + compNo not in self.staticComponents + or compNo + 1 not in self.staticComponents + ): continue self.staticComponents[compNo + 1] = Image.alpha_composite( self.staticComponents.pop(compNo), - self.staticComponents[compNo + 1] + self.staticComponents[compNo + 1], ) self.staticComponents[compNo] = None + mergeConsecutiveStaticComponentFrames(self) def frameRender(self, audioI): - ''' + """ Renders a frame composited together from the frames returned by each component audioI is a multiple of self.sampleSize, which can be divided to determine frameNo - ''' + """ + def err(): self.closePipe() self.cancelExport() self.error = True - msg = 'A call to renderFrame in the video thread failed critically.' + msg = "A call to renderFrame in the video thread failed critically." log.critical(msg) comp._error.emit(msg, str(e)) @@ -222,18 +235,16 @@ class Worker(QtCore.QObject): if frame is None: # bottom-most layer frame = comp.frameRender(bgI) else: - frame = Image.alpha_composite( - frame, comp.frameRender(bgI) - ) + frame = Image.alpha_composite(frame, comp.frameRender(bgI)) except Exception as e: err() return frame def showPreview(self, frame): - ''' + """ Receives a final frame that will be piped to FFmpeg, adds it to the MainWindow for the live preview - ''' + """ # We must store a reference to this QImage # or else Qt will garbage-collect it on the C++ side self.latestPreview = ImageQt(frame) @@ -241,7 +252,7 @@ class Worker(QtCore.QObject): @pyqtSlot() def createVideo(self): - ''' + """ 1. Numpy is set to ignore division errors during this method 2. Determine length of final video 3. Call preFrameRender on each component @@ -250,15 +261,14 @@ class Worker(QtCore.QObject): 6. Iterate over the audio data array and call frameRender on the components to get frames 7. Close the out_pipe 8. Call postFrameRender on each component - ''' + """ log.debug("Video worker received signal to createVideo") - log.debug( - 'Video thread id: {}'.format(int(QtCore.QThread.currentThreadId()))) - numpy.seterr(divide='ignore') + log.debug("Video thread id: {}".format(int(QtCore.QThread.currentThreadId()))) + numpy.seterr(divide="ignore") self.encoding.emit(True) self.extraAudio = [] - self.width = int(self.settings.value('outputWidth')) - self.height = int(self.settings.value('outputHeight')) + self.width = int(self.settings.value("outputWidth")) + self.height = int(self.settings.value("outputHeight")) # Set core.Core.canceled to False and call .reset() on each component self.reset() @@ -284,16 +294,18 @@ class Worker(QtCore.QObject): if not ffmpegCommand: return cmd = " ".join(ffmpegCommand) - print('###### FFMPEG COMMAND ######\n%s' % cmd) - print('############################') + print("###### FFMPEG COMMAND ######\n%s" % cmd) + print("############################") log.info(cmd) # Open pipe to FFmpeg - log.info('Opening pipe to FFmpeg') + log.info("Opening pipe to FFmpeg") try: self.out_pipe = openPipe( ffmpegCommand, - stdin=sp.PIPE, stdout=sys.stdout, stderr=sys.stdout + stdin=sp.PIPE, + stdout=sys.stdout, + stderr=sys.stdout, ) except sp.CalledProcessError: log.critical("Out_Pipe to FFmpeg couldn't be created!", exc_info=True) @@ -334,7 +346,7 @@ class Worker(QtCore.QObject): # Finished creating the video! # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - numpy.seterr(all='print') + numpy.seterr(all="print") self.closePipe() @@ -348,14 +360,14 @@ class Worker(QtCore.QObject): except Exception: pass self.progressBarUpdate.emit(0) - self.progressBarSetText.emit('Export Canceled') + self.progressBarSetText.emit("Export Canceled") else: if self.error: self.failExport() else: print("Export Complete") self.progressBarUpdate.emit(100) - self.progressBarSetText.emit('Export Complete') + self.progressBarSetText.emit("Export Complete") self.error = False self.canceled = False @@ -366,21 +378,21 @@ class Worker(QtCore.QObject): try: self.out_pipe.stdin.close() except (BrokenPipeError, OSError): - log.debug('Broken pipe to FFmpeg!') + log.debug("Broken pipe to FFmpeg!") if self.out_pipe.stderr is not None: log.error(self.out_pipe.stderr.read()) self.out_pipe.stderr.close() self.error = True self.out_pipe.wait() - def cancelExport(self, message='Export Canceled'): + def cancelExport(self, message="Export Canceled"): self.progressBarUpdate.emit(0) self.progressBarSetText.emit(message) self.encoding.emit(False) self.videoCreated.emit() def failExport(self): - self.cancelExport('Export Failed') + self.cancelExport("Export Failed") def updateProgress(self, pStr, pVal): self.progressBarValue.emit(pVal) |
