diff options
30 files changed, 2558 insertions, 2240 deletions
@@ -1,89 +1,102 @@ # Audio Visualizer Python + **We need a good name that is not as generic as "audio-visualizer-python"!** This is a little GUI tool which creates an audio visualization video from an input audio file. Different components can be added and layered to change the resulting video and add images, videos, gradients, text, etc. Encoding options can be changed with a variety of different output containers. The program works on **Linux**, **macOS**, and **Windows**. If you encounter problems running it or have other bug reports or features that you wish to see implemented, please fork the project and submit a pull request and/or file an [issue](https://github.com/djfun/audio-visualizer-python/issues) on this project. - # Screenshots & Videos + [<img title="AVP running on Windows" alt="Screenshot of program on Windows" src="screenshot.png" width="707">](/screenshot.png?raw=true) ## A video created by this app -* **[YouTube: A day in spring](https://www.youtube.com/watch?v=-M3jR1NuJHM)** 🎥 + +- **[YouTube: A day in spring](https://www.youtube.com/watch?v=-M3jR1NuJHM)** 🎥 ## Video demonstration of the app features -* [YouTube: Audio Visualizer Python v2.0.0 demonstration](https://www.youtube.com/watch?v=EVt2ckQs1Yg) 🎥 +- [YouTube: Audio Visualizer Python v2.0.0 demonstration](https://www.youtube.com/watch?v=EVt2ckQs1Yg) 🎥 # Installation + ## Installation on Ubuntu 24.04 -* Install dependencies: `sudo apt install ffmpeg pipx python3-pyqt5` -* Download this repo and run `pipx install .` in this directory -* Run the program with `avp` from terminal +- Install system dependencies: `sudo apt install ffmpeg` +- Make a virtual environment: `python -m venv env` +- Activate it: `source env/bin/activate` +- Install Python dependencies: `pip install pyqt6 pillow numpy` +- Install this program: `pip install .` in this directory +- Run the program with `avp` from terminal ## Installation on Windows -* Install Python from the Windows Store -* Add Python to your system PATH (it should ask during the installation process) -* Download this repo (extract from zip if needed) -* Download and install [FFmpeg](https://www.ffmpeg.org/download.html). Use the GPL-licensed static builds. -* Add FFmpeg to the system PATH as well (program will then work anywhere) - * Alternatively, copy ffmpeg.exe into the folder that you want to run the program within -* Open command prompt, `cd` into the repo directory, and run: `pip install .` -* Now run `avp` or `python -m avp` from a command prompt window to start the app + +- Install Python from the Windows Store +- Add Python to your system PATH (it should ask during the installation process) +- Download this repo (extract from zip if needed) +- Download and install [FFmpeg](https://www.ffmpeg.org/download.html). Use the GPL-licensed static builds. +- Add FFmpeg to the system PATH as well (program will then work anywhere) + - Alternatively, copy ffmpeg.exe into the folder that you want to run the program within +- Open command prompt, `cd` into the repo directory, and run: `pip install pyqt6 pillow numpy .` +- Now run `avp` or `python -m avp` from a command prompt window to start the app ## Installation on macOS -* We need help writing instructions for macOS, but the program should work in theory. + +- We need help writing instructions for macOS, but the program should work in theory. # [Keyboard Shortcuts](https://github.com/djfun/audio-visualizer-python/wiki/Keyboard-Shortcuts) -| Key Combo | Effect | -| ------------------------- | -------------------------------------------------- | -| Ctrl+S | Save Current Project | -| Ctrl+A | Save Project As... | -| Ctrl+O | Open Project | -| Ctrl+N | New Project (prompts to save current project) | -| Ctrl+Z | Undo | -| Ctrl+Shift+Z _or_ Ctrl+Y | Redo | -| Ctrl+T _or_ Insert | Add Component | -| Ctrl+R _or_ Delete | Remove Component | -| Ctrl+Space | Focus Component List | -| Ctrl+Shift+S | Save Component Preset | -| Ctrl+Shift+C | Remove Preset from Component | -| Ctrl+Up | Move Selected Component Up | -| Ctrl+Down | Move Selected Component Down | -| Ctrl+Home | Move Selected Component to Top | -| Ctrl+End | Move Selected Component to Bottom | -| Ctrl+Shift+U | Open Undo History | -| Ctrl+Shift+F | Show FFmpeg Command | +| Key Combo | Effect | +| ------------------------ | --------------------------------------------- | +| Ctrl+S | Save Current Project | +| Ctrl+A | Save Project As... | +| Ctrl+O | Open Project | +| Ctrl+N | New Project (prompts to save current project) | +| Ctrl+Z | Undo | +| Ctrl+Shift+Z _or_ Ctrl+Y | Redo | +| Ctrl+T _or_ Insert | Add Component | +| Ctrl+R _or_ Delete | Remove Component | +| Ctrl+Space | Focus Component List | +| Ctrl+Shift+S | Save Component Preset | +| Ctrl+Shift+C | Remove Preset from Component | +| Ctrl+Up | Move Selected Component Up | +| Ctrl+Down | Move Selected Component Down | +| Ctrl+Home | Move Selected Component to Top | +| Ctrl+End | Move Selected Component to Bottom | +| Ctrl+Shift+U | Open Undo History | +| Ctrl+Shift+F | Show FFmpeg Command | # Commandline Mode + Projects can be created with the GUI then loaded from the commandline for easy automation of video production. Some components have commandline options for extra customization, and you can save "presets" with settings to load if the commandline option doesn't exist. ## Example/test command -* Create a video with a grey "classic visualizer", background image, and text: - * `avp -c 0 image path=src/tests/data/test.jpg -c 1 classic color=180,180,180 -c 2 text "title=Episode 371" -i src/tests/data/test.ogg -o output.mp4` -* [See more about commandline mode in the wiki!](https://github.com/djfun/audio-visualizer-python/wiki/Commandline-Mode) +- Create a video with a grey "classic visualizer", background image, and text: + - `avp -c 0 image path=src/tests/data/test.jpg -c 1 classic color=180,180,180 -c 2 text "title=Episode 371" -i src/tests/data/test.ogg -o output.mp4` +- [See more about commandline mode in the wiki!](https://github.com/djfun/audio-visualizer-python/wiki/Commandline-Mode) # Developer Information + ## Known Working Versions of Dependencies -* Python 3.10 -* FFmpeg 4.4.1 -* PyQt5 (Qt v5.15.3) -* Pillow 9.1.0 -* NumPy 1.22.3 + +- Python 3.13 +- FFmpeg 8.0.1 +- PyQt6 v6.10.2 (Qt v6.10.1) +- Pillow 12.1.0 +- NumPy 2.4.1 ## Getting Faster Export Times -* [Pillow-SIMD](https://github.com/uploadcare/pillow-simd) may be used as a drop-in replacement for Pillow if you desire faster video export times, but it must be compiled from source. For help installing dependencies to compile Pillow-SIMD, see the [Pillow installation guide](https://pillow.readthedocs.io/en/stable/installation.html). Then add the `-SIMD` suffix into `setup.py` and install as usual. + +- [Pillow-SIMD](https://github.com/uploadcare/pillow-simd) may be used as a drop-in replacement for Pillow if you desire faster video export times, but it must be compiled from source. For help installing dependencies to compile Pillow-SIMD, see the [Pillow installation guide](https://pillow.readthedocs.io/en/stable/installation.html). ## Developing a New Component -* Information for developing a component is in our wiki: [How a Component Works](https://github.com/djfun/audio-visualizer-python/wiki/How-a-Component-Works) -* File an issue on GitHub if you need help fitting your visualizer into our component system; we would be happy to collaborate +- Information for developing a component is in our wiki: [How a Component Works](https://github.com/djfun/audio-visualizer-python/wiki/How-a-Component-Works) +- File an issue on GitHub if you need help fitting your visualizer into our component system; we would be happy to collaborate # License + Source code of audio-visualizer-python is licensed under the MIT license. Some dependencies of this application are under the GPL license. When packaged with these dependencies, audio-visualizer-python may also be under the terms of this GPL license. @@ -15,49 +15,53 @@ def getTextFromFile(filename, fallback): return output -PACKAGE_NAME = 'avp' -SOURCE_DIRECTORY = 'src' -SOURCE_PACKAGE_REGEX = re.compile(rf'^{SOURCE_DIRECTORY}') -PACKAGE_DESCRIPTION = 'Create audio visualization videos from a GUI or commandline' +PACKAGE_NAME = "avp" +SOURCE_DIRECTORY = "src" +SOURCE_PACKAGE_REGEX = re.compile(rf"^{SOURCE_DIRECTORY}") +PACKAGE_DESCRIPTION = "Create audio visualization videos from a GUI or commandline" avp = import_module(SOURCE_DIRECTORY) -source_packages = find_packages(include=[SOURCE_DIRECTORY, f'{SOURCE_DIRECTORY}.*']) -proj_packages = [SOURCE_PACKAGE_REGEX.sub(PACKAGE_NAME, name) for name in source_packages] +source_packages = find_packages(include=[SOURCE_DIRECTORY, f"{SOURCE_DIRECTORY}.*"]) +proj_packages = [ + SOURCE_PACKAGE_REGEX.sub(PACKAGE_NAME, name) for name in source_packages +] setup( - name='audio_visualizer_python', + name="audio_visualizer_python", version=avp.__version__, - url='https://github.com/djfun/audio-visualizer-python', - license='MIT', + url="https://github.com/djfun/audio-visualizer-python", + license="MIT", description=PACKAGE_DESCRIPTION, - author=getTextFromFile('AUTHORS', 'djfun, tassaron'), - long_description=getTextFromFile('README.md', PACKAGE_DESCRIPTION), + author=getTextFromFile("AUTHORS", "djfun, tassaron"), + long_description=getTextFromFile("README.md", PACKAGE_DESCRIPTION), classifiers=[ - 'Development Status :: 4 - Beta', - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3 :: Only', - 'Intended Audience :: End Users/Desktop', - 'Topic :: Multimedia :: Video :: Non-Linear Editor', + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3 :: Only", + "Intended Audience :: End Users/Desktop", + "Topic :: Multimedia :: Video :: Non-Linear Editor", ], keywords=[ - 'visualizer', 'visualization', 'commandline video', - 'video editor', 'ffmpeg', 'podcast' + "visualizer", + "visualization", + "commandline video", + "video editor", + "ffmpeg", + "podcast", ], packages=proj_packages, package_dir={PACKAGE_NAME: SOURCE_DIRECTORY}, include_package_data=True, install_requires=[ - 'Pillow==9.1.1', - 'PyQt5', - 'numpy', - 'pytest', - 'pytest-qt', + "Pillow", + "PyQt6", + "numpy", + "pytest", + "pytest-qt", ], entry_points={ - 'console_scripts': [ - f'avp = {PACKAGE_NAME}.__main__:main' - ], - } + "console_scripts": [f"avp = {PACKAGE_NAME}.__main__:main"], + }, ) 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) |
